Initial CST-Agent: portable Linux/Termux/Windows coding agent.

Vendored sashidown, OpenAI-compatible env (OPENAI_*), and bootstrap scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WHC
2026-09-07 22:42:32 +08:00
co-authored by Cursor
commit 1f1fcba7ad
75 changed files with 14940 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
*.o
cst-agent
cst-agent.exe
tests/test_*
!tests/test_*.c
session.jsonl
.agent/
fixtures/mini_calc/out/
fixtures/mini_calc/calc
fixtures/snake_cpp/snake
fixtures/snake_cpp/out/
build/
build-*/
dist/
third_party/busybox/busybox
third_party/busybox/busybox.exe
third_party/curl/
third_party/PDCurses/
third_party/busybox-w32/
third_party/busybox-src/
third_party/prefix-mingw/
vendor/sashidown/*.o
vendor/sashidown/src/*.o
vendor/sashidown/libsashidown.a
vendor/sashidown/libsashidown-mingw.a
+160
View File
@@ -0,0 +1,160 @@
cmake_minimum_required(VERSION 3.16)
project(cst-agent C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON)
option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF)
option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
set(SD_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sashidown")
if(CST_NATIVE_ARCH)
set(_SD_MAKE_EXTRA CST_NATIVE_ARCH=1)
else()
set(_SD_MAKE_EXTRA "")
endif()
# Separate archive for MinGW so Linux host builds keep libsashidown.a
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(SASHIDOWN_A "${SD_DIR}/libsashidown-mingw.a")
set(_MINGW_AR x86_64-w64-mingw32-ar)
add_custom_command(
OUTPUT "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a
CC=${CMAKE_C_COMPILER} AR=${_MINGW_AR} RANLIB=x86_64-w64-mingw32-ranlib
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean
COMMENT "Building libsashidown for MinGW"
VERBATIM)
else()
set(SASHIDOWN_A "${SD_DIR}/libsashidown.a")
add_custom_command(
OUTPUT "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a CC=${CMAKE_C_COMPILER} ${_SD_MAKE_EXTRA}
COMMENT "Building libsashidown.a"
VERBATIM)
endif()
add_custom_target(sashidown DEPENDS "${SASHIDOWN_A}")
find_package(CURL REQUIRED)
find_package(Threads REQUIRED)
set(CST_SOURCES
src/ctl_parse.c
src/os_compat.c
src/sandbox.c
src/session.c
src/tools/tools.c
src/llm_openai.c
src/agent_loop.c
src/agent_store.c
src/json_baseline.c
src/creds.c
src/mm_tool.c
src/models.c
src/main.c
)
if(CST_BUILD_TUI)
list(APPEND CST_SOURCES src/tui.c)
else()
list(APPEND CST_SOURCES src/tui_stub.c)
endif()
add_executable(cst-agent ${CST_SOURCES})
add_dependencies(cst-agent sashidown)
target_include_directories(cst-agent PRIVATE
include
"${SD_DIR}/include"
)
target_compile_definitions(cst-agent PRIVATE _GNU_SOURCE)
if(NOT WIN32)
target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED)
endif()
target_link_libraries(cst-agent PRIVATE
"${SASHIDOWN_A}"
CURL::libcurl
Threads::Threads
)
if(NOT WIN32)
target_link_libraries(cst-agent PRIVATE m)
endif()
if(CST_BUILD_TUI)
if(WIN32)
# Prefer PDCurses (pdcurses / curses). Users may set PDCurses_ROOT.
find_path(PDCURSES_INCLUDE_DIR curses.h
HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
PATH_SUFFIXES include)
find_library(PDCURSES_LIBRARY
NAMES pdcurses pdcurses.dll curses
HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
PATH_SUFFIXES lib)
if(PDCURSES_INCLUDE_DIR AND PDCURSES_LIBRARY)
target_include_directories(cst-agent PRIVATE ${PDCURSES_INCLUDE_DIR})
target_link_libraries(cst-agent PRIVATE ${PDCURSES_LIBRARY})
target_compile_definitions(cst-agent PRIVATE CST_USE_PDCURSES=1)
message(STATUS "TUI: PDCurses (${PDCURSES_LIBRARY})")
else()
message(FATAL_ERROR "CST_BUILD_TUI=ON on Windows requires PDCurses (set PDCurses_ROOT)")
endif()
else()
set(CURSES_NEED_WIDE TRUE)
find_package(Curses)
if(CURSES_FOUND)
target_include_directories(cst-agent PRIVATE ${CURSES_INCLUDE_DIRS})
target_link_libraries(cst-agent PRIVATE ${CURSES_LIBRARIES})
else()
# Fallback common on Linux distros
target_link_libraries(cst-agent PRIVATE ncursesw)
endif()
target_compile_definitions(cst-agent PRIVATE CST_USE_NCURSES=1)
endif()
else()
target_compile_definitions(cst-agent PRIVATE CST_HEADLESS_ONLY=1)
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR WIN32)
# curl Schannel + networking
target_link_libraries(cst-agent PRIVATE
ws2_32 crypt32 secur32 bcrypt advapi32)
endif()
# --- tests ---
enable_testing()
foreach(t test_ctl_frontier test_json_baseline test_replay_starvation test_mm_tool)
if(t STREQUAL test_ctl_frontier)
add_executable(${t} tests/test_ctl_frontier.c src/ctl_parse.c)
elseif(t STREQUAL test_json_baseline)
add_executable(${t} tests/test_json_baseline.c src/json_baseline.c)
elseif(t STREQUAL test_replay_starvation)
add_executable(${t} tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c)
else()
add_executable(${t} tests/test_mm_tool.c src/mm_tool.c)
endif()
target_include_directories(${t} PRIVATE include)
target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
add_test(NAME ${t} COMMAND ${t})
endforeach()
# --- busybox bundle helpers ---
if(CST_BUNDLE_BUSYBOX)
install(FILES
"${CMAKE_CURRENT_SOURCE_DIR}/third_party/busybox/README.md"
DESTINATION .
OPTIONAL)
if(WIN32 AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/busybox/busybox.exe")
install(PROGRAMS "${CMAKE_CURRENT_SOURCE_DIR}/third_party/busybox/busybox.exe" DESTINATION .)
endif()
endif()
install(TARGETS cst-agent RUNTIME DESTINATION .)
message(STATUS "Compiler: ${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER}")
message(STATUS "CST_BUILD_TUI=${CST_BUILD_TUI}")
+77
View File
@@ -0,0 +1,77 @@
CC ?= gcc
CFLAGS ?= -std=c11 -Wall -Wextra -O2 -g
SD_DIR := vendor/sashidown
CPPFLAGS += -Iinclude -I$(SD_DIR)/include -D_XOPEN_SOURCE_EXTENDED
LDFLAGS += -L$(SD_DIR)
LDLIBS += -lsashidown -lm -lcurl -lncursesw -lpthread
SRC = \
src/ctl_parse.c \
src/os_compat.c \
src/sandbox.c \
src/session.c \
src/tools/tools.c \
src/llm_openai.c \
src/agent_loop.c \
src/agent_store.c \
src/json_baseline.c \
src/creds.c \
src/mm_tool.c \
src/models.c \
src/tui.c \
src/main.c
OBJ = $(SRC:.c=.o)
.PHONY: all clean check demo sashidown-lib cmake-gcc cmake-clang
all: sashidown-lib cst-agent
# Optional CMake entrypoints (same sources)
cmake-gcc:
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
cmake --build build-gcc
ctest --test-dir build-gcc --output-on-failure
cmake-clang:
cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
cmake --build build-clang
sashidown-lib:
$(MAKE) -C $(SD_DIR) libsashidown.a
cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
$(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
src/%.o: src/%.c include/ctl.h include/cst_agent.h
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
src/tools/%.o: src/tools/%.c include/ctl.h include/cst_agent.h
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
src/tui.o: src/tui.c include/cst_agent.h $(SD_DIR)/include/sashidown.h
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
tests/test_ctl_frontier: tests/test_ctl_frontier.c src/ctl_parse.c include/ctl.h
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_ctl_frontier.c src/ctl_parse.c
tests/test_json_baseline: tests/test_json_baseline.c src/json_baseline.c include/cst_agent.h
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_json_baseline.c src/json_baseline.c
tests/test_replay_starvation: tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_replay_starvation.c src/ctl_parse.c src/json_baseline.c
tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c
check: tests/test_ctl_frontier tests/test_json_baseline tests/test_replay_starvation tests/test_mm_tool
./tests/test_ctl_frontier
./tests/test_json_baseline
./tests/test_replay_starvation
./tests/test_mm_tool
demo: cst-agent
./cst-agent --demo-ctl --workspace .
clean:
rm -f $(OBJ) cst-agent tests/test_ctl_frontier tests/test_json_baseline tests/test_replay_starvation tests/test_mm_tool
+198
View File
@@ -0,0 +1,198 @@
# CST-Agent
C11 OpenCode-like carrier that extends paper **CST** (confirmation frontier / irreversible emission) from Markdown→HTML to the **agent tool loop**.
See [docs/theory_tool_cst.md](docs/theory_tool_cst.md).
评测/反哺论文素材:[docs/eval_tool_cst.md](docs/eval_tool_cst.md)。
## 能不能用来工作?
**可以**:有监督的本地小改动 / 探索本仓库(TUI + 工具 + 权限确认)。
**不宜**:无人值守长任务或当 CI 主力(网关偶发空答、无完整 diff UI)。
### 5 分钟试用
```bash
# 一键拉取编译(Linux)
curl -fsSL https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/bootstrap_linux.sh | bash
# 或已有仓库:
cd CST-agent && ./scripts/bootstrap_linux.sh .
# OpenAI 官方
export OPENAI_API_KEY='sk-...'
export OPENAI_BASE_URL='https://api.openai.com/v1'
export OPENAI_MODEL='gpt-4.1-mini'
./cst-agent --workspace .
```
完整环境变量说明:[docs/env_openai.md](docs/env_openai.md)。
| 平台 | 一键脚本 |
|---|---|
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/` |
| Windows 运行 | 拷贝 dist 后 [`scripts/run_windows.bat`](scripts/run_windows.bat) |
### 迷你验收工程(论文案例)
故意带 bug 的小计算器:[`fixtures/mini_calc/`](fixtures/mini_calc/)(任务书 `TASK.md`)。
```bash
./scripts/e2e_mini_calc.sh --baseline-only # 无 API:证明初始测试 RED
./scripts/e2e_mini_calc.sh # 有 key:agent 修复后 make test
```
口径:`pass` / `partial` / `fail` — 见 [docs/eval_tool_cst.md](docs/eval_tool_cst.md)。
## Build
### Make(Linux 默认)
```bash
cd agent
make
make check
```
### CMake(推荐跨编译器 / 交叉)
```bash
# GCC
cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON
cmake --build build-gcc && ctest --test-dir build-gcc
# Clang
cmake -B build-clang -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
cmake --build build-clang
# Headless only(无 ncurses)
cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
# Windows MinGW 交叉(需安装 mingw-w64)
cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=OFF
cmake --build build-win
# TUI:安装 PDCurses 后 -DCST_BUILD_TUI=ON -DPDCurses_ROOT=...
```
依赖:`gcc` 或 `clang`、`libcurl`、Linux TUI 需 `ncursesw`;Windows TUI 需 **PDCurses**;sibling `../SD`(`libsashidown.a`)。
API Key:env / 配置文件 / **zenity(Linux)或控制台输入(Windows / 无 zenity)**。
## 平台支持矩阵
| OS | Arch | 编译器 | Headless | TUI | Shell |
|---|---|---|---|---|---|
| Linux | x86_64 / aarch64 | GCC, Clang | yes | ncursesw | `/bin/sh` 或旁路 `busybox` |
| Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox |
| Windows | x86_64 / aarch64 | MinGW GCC/Clang | yes | PDCurses | **捆绑 busybox-w32** |
| macOS | — | — | **不做** | — | — |
### Termux
```bash
pkg install clang make cmake libcurl ncurses-ui-libs
cmake -B build -DCST_BUILD_TUI=ON && cmake --build build
# 可选:旁路 Unix busybox
# ./scripts/build_third_party_mingw.sh busybox-unix
```
环境:`PREFIX` 自动用于找 `sh`;`CST_EXE_DIR` / `argv[0]` 用于定位旁路 busybox(`/proc/self/exe` 受限时)。
### Windows 发行包
见 [docs/README-windows.md](docs/README-windows.md)。交叉构建:
```bash
./scripts/build_third_party_mingw.sh all # curl+PDCurses+busybox-w32
# 源码:https://github.com/rmyorston/busybox-w32.git
# https://github.com/mirror/busybox.git (Unix)
cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=ON -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw
```
产物示例:`dist/windows-x86_64/`(含 `cst-agent.exe` + `busybox.exe` + `libwinpthread-1.dll`)。
环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。
## Defaults (API)
默认 MiniMax:`https://api.minimax.cn/v1` / `MiniMax-M2.7`。
对接 **OpenAI 官方或其它兼容网关** 时设置:
| 用途 | 推荐变量 | 等价别名 |
|---|---|---|
| Key | `OPENAI_API_KEY` | `CST_API_KEY`、`MINIMAX_API_KEY` |
| Base(含 `/v1`) | `OPENAI_BASE_URL` | `CST_API_BASE`、`OPENAI_API_BASE` |
| Model | `OPENAI_MODEL` | `CST_MODEL` |
详见 [docs/env_openai.md](docs/env_openai.md)。
| 项 | 默认(未设 OPENAI_* 时) |
|---|---|
| API Base | `https://api.minimax.cn/v1` |
| Model | `MiniMax-M2.7` |
| API Key | env → 配置目录 credentials → zenity/控制台 |
环境变量:`CST_MODELS`、`CST_AUTO_APPROVE=1`、`CST_SHELL`、`CST_EXE_DIR`。
## Run
```bash
# Local CTL demo (no API)
./cst-agent --demo-ctl --workspace .
# TUI(OpenCode 风格;Assistant Markdown 经 Sashidown CST 渲染)
./cst-agent --workspace ..
# 强制重新弹窗保存 Key
./cst-agent --ask-key --workspace .
# Headless(默认 auto-approve shell/写文件)
./cst-agent --headless --prompt 'List files' --workspace .
```
### TUI 快捷键与斜杠命令
| 操作 | 说明 |
|---|---|
| Enter | 发送;空输入展开最近 tool 卡;busy 时入队(最多 3) |
| Ctrl+N | 输入换行(多行 composer) |
| ↑ / ↓ | 提示历史 |
| Esc / Ctrl+C | busy 时取消当前 turn |
| Tab / `t`(空输入) | 展开/折叠 Thought |
| 权限 | shell/写文件:`y` 允许 · `n` 拒绝 · `a` 本进程对该工具 Always |
| `/new` | 新会话 |
| `/session` | **可点选**会话列表(↑↓ Enter);`/session <id>` 手打切换 |
| `/model` | **可点选**模型;`/model NAME` 直接设置(进程内) |
| `/compact` | 压缩上下文 |
| `/details` | 展开/折叠全部 tool 输出 |
| `/help` | 帮助 |
会话落盘:`<workspace>/.agent/sessions/<id>.jsonl` + `CURRENT`。
### 协议
- OpenAI 兼容 `POST …/chat/completions`:原生 `tools` / SSE `tool_calls`,answer 轮 `tool_choice:none`
- MiniMax XML `<minimax:tool_call>` 作 fallback
- Assistant 仅工具时 `content: null` + `tool_calls`(对齐 OpenCode/OpenAI Chat)
## CTL tool language
```text
call <id> <name>
arg <key> <nbytes>
<raw bytes exactly nbytes>
end
```
Execution happens only on confirmed `end` (`CTL_EV_CALL_READY`).
## Starvation experiment
```bash
make check
./scripts/replay_starvation.sh
```
CTL vs JSON-buffer 截断数字写入 [docs/eval_tool_cst.md](docs/eval_tool_cst.md) 实验 A;与 mini_calc e2e 一起作为 Tool-loop CST 附录素材(**不**替代 SD 主论文 Markdown→HTML 实验)。
+12
View File
@@ -0,0 +1,12 @@
# Usage:
# cmake -B build-win-arm -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-aarch64.cmake \
# -DCST_BUILD_TUI=OFF
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER aarch64-w64-mingw32-gcc)
set(CMAKE_CXX_COMPILER aarch64-w64-mingw32-g++)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+21
View File
@@ -0,0 +1,21 @@
# Usage:
# ./scripts/build_third_party_mingw.sh curl
# cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
# -DCST_BUILD_TUI=OFF
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
get_filename_component(_AGENT_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE)
set(_MINGW_PREFIX "${_AGENT_ROOT}/third_party/prefix-mingw")
list(APPEND CMAKE_PREFIX_PATH "${_MINGW_PREFIX}")
set(CMAKE_FIND_ROOT_PATH "/usr/x86_64-w64-mingw32" "${_MINGW_PREFIX}")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY BOTH)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE BOTH)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE BOTH)
set(PDCurses_ROOT "${_MINGW_PREFIX}" CACHE PATH "PDCurses prefix")
+53
View File
@@ -0,0 +1,53 @@
# CST-Agent on Windows
## Release layout
```
cst-agent.exe
busybox.exe # built from busybox-w32
libwinpthread-1.dll # MinGW runtime (required)
LICENSE.busybox
README-windows.md
```
Prebuilt tree from Linux cross-compile: `agent/dist/windows-x86_64/`.
## Build (MinGW-w64 cross from Linux)
```bash
cd agent
# 1) third-party: curl (Schannel) + PDCurses + busybox-w32
./scripts/build_third_party_mingw.sh all
# sources:
# https://github.com/rmyorston/busybox-w32.git
# https://github.com/curl/curl.git
# https://github.com/wmcbrine/PDCurses.git
# Unix busybox (optional, Linux/Termux bundle):
# ./scripts/build_third_party_mingw.sh busybox-unix
# https://github.com/mirror/busybox.git
# 2) headless
cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=OFF -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw
cmake --build build-win
# 3) TUI (PDCurses)
cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=ON -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw \
-DPDCurses_ROOT=$PWD/third_party/prefix-mingw
cmake --build build-win-tui
```
Copy `build-win-tui/cst-agent.exe`, `third_party/busybox/busybox.exe`, and
`libwinpthread-1.dll` into one folder.
## Run
```bat
set CST_API_KEY=...
cst-agent.exe --headless --workspace . --prompt "echo hi"
cst-agent.exe --workspace .
```
Shell: bundled `busybox.exe sh -c ...` unless `CST_SHELL` is set.
Credentials: `%APPDATA%\cst-agent\credentials` (or console prompt).
+73
View File
@@ -0,0 +1,73 @@
# OpenAI 兼容 API 环境变量
CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completions`),
可对接官方 OpenAI、MiniMax、以及其它兼容网关。
## 必填 / 常用
| 变量 | 别名 | 说明 | 示例 |
|---|---|---|---|
| `CST_API_KEY` | `OPENAI_API_KEY`、`MINIMAX_API_KEY` | API Key(按此优先级读取) | `sk-...` |
| `CST_API_BASE` | `OPENAI_BASE_URL`、`OPENAI_API_BASE` | API 根路径,须含 `/v1`(不要带 `/chat/completions`) | `https://api.openai.com/v1` |
| `CST_MODEL` | `OPENAI_MODEL` | 模型名 | `gpt-4.1-mini` / `MiniMax-M2.7` |
未设置 key 时:读配置文件 →(Linux)zenity / 控制台输入。
配置文件:
- Linux/Termux:`~/.config/cst-agent/credentials`
- Windows:`%APPDATA%\cst-agent\credentials`
## 示例
### OpenAI 官方
```bash
export OPENAI_API_KEY='sk-...'
export OPENAI_BASE_URL='https://api.openai.com/v1'
export OPENAI_MODEL='gpt-4.1-mini'
./cst-agent --workspace .
```
等价 CST 前缀:
```bash
export CST_API_KEY='sk-...'
export CST_API_BASE='https://api.openai.com/v1'
export CST_MODEL='gpt-4.1-mini'
```
### MiniMax(默认)
```bash
export CST_API_KEY='...' # 或 MINIMAX_API_KEY
# 默认 base=https://api.minimax.cn/v1 model=MiniMax-M2.7
./cst-agent --workspace .
```
### 任意兼容网关
```bash
export OPENAI_API_KEY='...'
export OPENAI_BASE_URL='https://your-gateway.example/v1'
export OPENAI_MODEL='your-model'
./cst-agent --headless --prompt 'ping' --workspace .
```
命令行也可覆盖:`--api-base` / `--api-key` / `--model`。
## 其它环境变量
| 变量 | 说明 |
|---|---|
| `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 |
| `CST_AUTO_APPROVE=1` | 自动允许 shell/写文件(headless 常用) |
| `CST_SHELL` | shell 解释器路径(覆盖 busybox/`sh`) |
| `CST_EXE_DIR` | 可执行文件目录(Termux 旁路 busybox 时有用) |
## CLI 对照
```text
--api-base URL ← CST_API_BASE / OPENAI_BASE_URL
--api-key KEY ← CST_API_KEY / OPENAI_API_KEY
--model NAME ← CST_MODEL / OPENAI_MODEL
```
+73
View File
@@ -0,0 +1,73 @@
# Tool-loop CST 评测笔记(反哺论文素材)
> 本文是 **CST-Agent** 侧的评测/案例记录,供写入 SD 主论文附录或独立 TR 时粘贴。
> **不**替代 [`SD/paper/sections/eval_zh.tex`](../../SD/paper/sections/eval_zh.tex) 中 Markdown→HTML 主实验。
理论锚点见 [theory_tool_cst.md](theory_tool_cst.md)。
## 命题分工
| 层 | 主张 | 主变量 | 非主张 |
|---|---|---|---|
| 论文 CST | \(O_{\mathrm{conf}}\) 有界确认 → HTML | \(\mathcal{L}_0\) vs 引用式链接等 | — |
| Tool-loop CST | 同构到工具帧 | **CTL vs JSON-buffer** 截断 | 网关空答率、模型智商 |
| 工程载体 | OpenAI `tool_calls` + XML fallback | 可演示端到端 | 不算进 CST 正确性定理 |
## 实验 A — 饥饿 / 截断(已自动化)
```bash
cd agent && make check
# 关注 ./tests/test_replay_starvation
```
填写模板(跑完后更新数字):
| 指标 | CTL | JSON-buffer |
|---|---|---|
| 完整输入可执行 | yes | yes |
| 截断点上过早执行次数 | **0** | n/a(截断即失败) |
| 安全截断样本(本机 `make check`) | 3 | 3 次 trunc failure |
对应理论:裸 JSON ∉ \(\mathcal{L}_0^{\mathrm{tool}}\);CTL 长度前缀使参数体字节可确认,pending 有界。
## 实验 B — mini_calc 端到端案例(不可逆发射)
协议见 [fixtures/mini_calc/](../fixtures/mini_calc/) 与 [scripts/e2e_mini_calc.sh](../scripts/e2e_mini_calc.sh)。
```bash
cd agent
./scripts/e2e_mini_calc.sh --baseline-only # 无 key:证明 fixture 初始 RED
./scripts/e2e_mini_calc.sh # 有 key:agent 修 bug → make test
```
### 验收口径
| outcome | 含义 |
|---|---|
| `pass` | `make test` 绿 |
| `partial` | 改了 `calc.c` 但测试仍红 |
| `fail` | 未改文件或 agent/API 失败 |
| `baseline_ok_agent_skipped` / `baseline_ok_no_api_key` | 仅验证夹具 |
### 论文可引用的叙事
1. Session JSONL 中顺序:assistant `tool_calls` → `role:tool` 结果 →(可选)写文件/shell → 最终文本。
2. 工具仅在确认/原生完整 arguments 后执行(工程桥);**CTL 饥饿实验**仍是形式主证据。
3. 案例展示「确认后不可撤回的磁盘副作用」与 transcript 只追加。
### 跑分记录(模板)
| 日期 (UTC) | model | outcome | notes |
|---|---|---|---|
| 2026-09-07 | MiniMax-M2.7 | **pass** | `e2e_mini_calc.sh`;baseline RED → agent 修 `add`/`div_ints` → `make test` 绿;见 `out/result-20260907T131300Z.txt` |
## 与主论文的衔接建议
- **附录 / 扩展节标题示例**:「Tool-loop CST:确认前沿到工具调用」
- 粘贴:theory 表(theory_tool_cst.md)+ 实验 A 表 + 实验 B 一行案例。
- 明确声明:主 fan-out / \(\mathcal{A}=0\) 数字仍来自 Sashidown;本笔记只提供 **同构第二实例**。
## 明确不写进定理的内容
- 商业 API 空回复、权限 UX、TUI 观感
- 「已达生产 coding agent」类宣称
+60
View File
@@ -0,0 +1,60 @@
# Tool-loop CST:确认前沿推广到工具调用
## 命题
在 agent 工具环中,对模型输出做**确认流式转导**:只在 \(O_{\mathrm{conf}}\) 内不可逆地发射「可执行工具调用 / 可应用补丁 / 可提交会话状态」。相对「拼到合法 JSON 再执行」的缓冲基线,这能降低注意力饥渴导致的残缺工具帧(未闭合括号、半截 arguments)被过早执行或整轮失败的问题。
## 与论文 CST 的对应
| 论文(Markdown→HTML) | 工具环推广 |
|---|---|
| 输入字母表 \(\Sigma\) | 模型文本 / CTL 字节流 |
| 指称 \(f,g\) → HTML | \(f_{\mathrm{tool}}\) → 已确认 `ToolCall` / `PatchHunk` |
| \(O_{\mathrm{conf}}\) 不可撤回 HTML | 不可撤回:开始执行工具、应用 hunk、写入 transcript |
| \(\mathcal{L}_0\) 排除引用式链接 | \(\mathcal{L}_0^{\mathrm{tool}}\) **排除裸 JSON 对象** |
| \(K=2L_{\mathrm{line}}\) | 工具帧同样要求有界 pending |
## 为何排除裸 JSON
JSON 对象依赖括号匹配与字符串转义。在未闭合 `"` / `{` 时,任意后缀都可能改变已见结构的指称,故 \(O_{\mathrm{conf}}\) 往往停在空前缀,pending 随流长增长——这与论文中引用式链接的**无界后向绑定**同构。「等整个 JSON `Parse` 成功」只是 EOF 缓冲,不是有界 CST。
## \(\mathcal{L}_0^{\mathrm{tool}}\):CTL(CST Tool Language)
行界 \(L_{\mathrm{line}}\);参数体用长度前缀,避免转义歧义。
```text
call <id> <name>\n
arg <key> <nbytes>\n
<raw bytes exactly nbytes>
end\n
```
### 确认规则
1. **`call` 行以 `\n` 结束**:确认 `(id, name)`(此后不可撤回工具身份)。
2. **`arg` 头以 `\n` 结束**:确认 `(key, nbytes)`;随后精确消费 `nbytes` 字节,每字节到达即可确认(无 JSON 转义)。
3. **`end\n`**:确认整次调用可执行;转导器发射一个 `ToolCall` 事件。
4. 行长 \(> L_{\mathrm{line}}\)、非法 token、`nbytes` 超上限、非 CTL 噪声 → 离开契约,标记 `tool_parse_error`。
### 有界 pending \(K\)
- 解析行头时 pending \(\le L_{\mathrm{line}}\)。
- 读参数体时 pending \(\le\) 剩余未读字节数,但**已读体字节已确认**,工作区只需当前行缓冲 + 当前 arg 剩余计数(\(O(L_{\mathrm{line}})\) 寄存器),不随会话总长增长。
取 \(K = L_{\mathrm{line}}\)(行头)与实现常量 `CTL_MAX_ARG`(单参上限,契约拒绝更大值)。
## 不可逆发射
实现是确认忠实的,当且仅当:
- 拼接发射的工具事件序列是 \(O_{\mathrm{conf}}(u)\) 的前缀;
- 一旦 `ToolCall` 被发射,runtime **必须**执行(或显式记录拒绝),不得因后续字节撤回;
- session transcript 只追加:用户消息、已确认助手文本、工具调用与结果。
## 对照基线
**JSON-buffer**:将模型输出拼接到可 `cJSON_Parse` 的 `{"name","arguments"}`(或 OpenAI `tool_calls` 形)才执行;流中途截断则失败。与 CTL 使用同一截断点集合,只换帧语法。
## 补丁确认
`apply_patch` 的 patch 文本作为 CTL 的一个 `arg`;应用侧再要求 unified diff 的每个 hunk 完整(`@@` 到下一块或 EOF)才写盘——整 hunk 确认,禁止半截应用。
+11
View File
@@ -0,0 +1,11 @@
# Windows self-test checklist
Run on a Windows machine (or wine) after MinGW build + busybox.exe beside the binary.
1. `cst-agent.exe --help` exits 0
2. `set CST_API_KEY=...` then `--headless --workspace . --prompt "run echo hello"` — tool output contains hello
3. `write_file` under workspace succeeds; path outside workspace rejected
4. Credentials saved under `%APPDATA%\cst-agent\credentials`
5. With PDCurses build: launch without `--headless`, confirm TUI draws, `/help` works, Esc cancels
Shell must resolve to bundled busybox unless `CST_SHELL` is set.
+15
View File
@@ -0,0 +1,15 @@
CC ?= gcc
CFLAGS ?= -std=c11 -Wall -Wextra -O2
.PHONY: all clean test
all: calc
calc: calc.c
$(CC) $(CFLAGS) -o $@ $<
test: calc
./tests/smoke.sh
clean:
rm -f calc
+19
View File
@@ -0,0 +1,19 @@
# mini_calc
Tiny integer calculator CLI for CST-Agent e2e demos.
## Ops
`add` `sub` `mul` `div` — all four work (see `make test`).
```bash
./calc add 2 3 # → 5
./calc div 8 2 # → 4
```
## Build
```bash
make
make test
```
+11
View File
@@ -0,0 +1,11 @@
# 任务:修好 mini_calc
你在一个很小的 C 项目里。请完成下列事项后结束(不要无关重构):
1. 阅读 `README.md`、`calc.c`、`tests/smoke.sh`。
2. 修复 `add` 的 off-by-one bug(应为 `a+b`,不是 `a+b+1`)。
3. 实现真正的整数除法 `div_ints`(`a/b`,调用方已保证 `b!=0`)。
4. 运行 `make test`,确保全部用例通过。
5. 用简短中文说明你改了什么。
可用工具:`read_file`、`run_shell`、`write_file` / `apply_patch`。工作区根目录就是本项目。
+58
View File
@@ -0,0 +1,58 @@
/* mini_calc — intentionally broken fixture for CST-Agent e2e / paper case study.
*
* Bugs (for the agent to fix):
* 1) add(): off-by-one — returns a+b+1
* 2) div(): missing — only stub returns 0
* README claims all four ops work (false until fixed).
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int add(int a, int b) {
/* BUG: off-by-one */
return a + b + 1;
}
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int div_ints(int a, int b) {
/* BUG: not implemented */
(void)a;
(void)b;
return 0;
}
static void usage(const char *argv0) {
fprintf(stderr, "Usage: %s <add|sub|mul|div> <a> <b>\n", argv0);
}
int main(int argc, char **argv) {
if (argc != 4) {
usage(argv[0]);
return 2;
}
int a = atoi(argv[2]);
int b = atoi(argv[3]);
int r = 0;
if (strcmp(argv[1], "add") == 0)
r = add(a, b);
else if (strcmp(argv[1], "sub") == 0)
r = sub(a, b);
else if (strcmp(argv[1], "mul") == 0)
r = mul(a, b);
else if (strcmp(argv[1], "div") == 0) {
if (b == 0) {
fprintf(stderr, "div by zero\n");
return 3;
}
r = div_ints(a, b);
} else {
usage(argv[0]);
return 2;
}
printf("%d\n", r);
return 0;
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Smoke tests for mini_calc. Exit 0 only when add/sub/mul/div are correct.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CALC="$ROOT/calc"
fail=0
expect() {
local op=$1 a=$2 b=$3 want=$4
local got
got="$("$CALC" "$op" "$a" "$b")"
if [[ "$got" != "$want" ]]; then
echo "FAIL: $op $a $b → got '$got' want '$want'"
fail=1
else
echo "ok: $op $a $b → $got"
fi
}
expect add 2 3 5
expect sub 9 4 5
expect mul 3 4 12
expect div 8 2 4
if [[ "$fail" -ne 0 ]]; then
echo "smoke: FAILED"
exit 1
fi
echo "smoke: PASSED"
exit 0
+10
View File
@@ -0,0 +1,10 @@
CXX = g++
CXXFLAGS = -O2 -std=c++17 -Wall -Wextra
snake: snake.cpp
$(CXX) $(CXXFLAGS) -o snake snake.cpp
clean:
rm -f snake
.PHONY: clean
+51
View File
@@ -0,0 +1,51 @@
# Snake Game
A classic terminal-based Snake game written in C++17.
## Features
- Smooth snake movement using POSIX terminal I/O
- WASD or Arrow keys for movement
- Food spawning and snake growth
- Wall and self-collision detection
- Score tracking
- Pause functionality (P key)
- Clean terminal rendering
## Build
```bash
make
```
This compiles `snake.cpp` with optimization level O2 and C++17 standard.
## Run
```bash
./snake
```
## Controls
| Key | Action |
|-----|--------|
| W / ↑ | Move Up |
| S / ↓ | Move Down |
| A / ← | Move Left |
| D / → | Move Right |
| P | Pause/Resume |
| Q | Quit |
## Game Rules
- Eat the `*` food to grow and score points (+10 per food)
- Avoid hitting the walls `#`
- Avoid colliding with your own body `o`
- The snake moves continuously
## Requirements
- GCC/G++ with C++17 support
- POSIX-compliant system (Linux, macOS)
- Terminal with ANSI escape support
+10
View File
@@ -0,0 +1,10 @@
Write a complete C++ terminal Snake game in this workspace.
Requirements:
1. Single file `snake.cpp` (C++17 ok). Use POSIX terminal I/O: raw mode or ncurses; WASD or arrow keys; quit with `q`.
2. Features: moving snake, food, grow on eat, wall or self collision ends game, score printed, board roughly 20x20 or similar.
3. `Makefile` with target `snake` that runs `g++ -O2 -o snake snake.cpp` (link `-lncurses` only if you use ncurses).
4. After writing, run `make` to verify it compiles. Do not need an interactive play session.
5. Brief README.md with how to build and run.
Use write_file / run_shell tools. Prefer implementing yourself rather than downloading.
+224
View File
@@ -0,0 +1,224 @@
#include <iostream>
#include <vector>
#include <deque>
#include <cstdlib>
#include <ctime>
#include <cstring>
#include <chrono>
#include <thread>
#include <conio.h>
// Game constants
constexpr int BOARD_WIDTH = 20;
constexpr int BOARD_HEIGHT = 15;
constexpr char WALL = '#';
constexpr char SNAKE_BODY = 'o';
constexpr char SNAKE_HEAD = 'O';
constexpr char FOOD = '*';
constexpr char EMPTY = ' ';
// Directions
enum class Direction { UP, DOWN, LEFT, RIGHT };
struct Position {
int x, y;
bool operator==(const Position& other) const {
return x == other.x && y == other.y;
}
};
struct GameState {
std::deque<Position> snake;
Position food;
Direction dir;
Direction next_dir;
int score;
bool game_over;
bool paused;
};
char board[BOARD_HEIGHT][BOARD_WIDTH + 1];
void place_food(GameState& state);
void init_board() {
for (int y = 0; y < BOARD_HEIGHT; y++) {
for (int x = 0; x < BOARD_WIDTH; x++) {
if (y == 0 || y == BOARD_HEIGHT - 1 || x == 0 || x == BOARD_WIDTH - 1) {
board[y][x] = WALL;
} else {
board[y][x] = EMPTY;
}
}
board[y][BOARD_WIDTH] = '\0';
}
}
void init_game(GameState& state) {
Position head = {BOARD_WIDTH / 2, BOARD_HEIGHT / 2};
state.snake = {head, {head.x - 1, head.y}, {head.x - 2, head.y}};
state.dir = Direction::RIGHT;
state.next_dir = Direction::RIGHT;
state.score = 0;
state.game_over = false;
state.paused = false;
place_food(state);
}
void place_food(GameState& state) {
Position pos;
bool on_snake;
do {
on_snake = false;
pos.x = 1 + std::rand() % (BOARD_WIDTH - 2);
pos.y = 1 + std::rand() % (BOARD_HEIGHT - 2);
for (const auto& segment : state.snake) {
if (segment == pos) {
on_snake = true;
break;
}
}
} while (on_snake);
state.food = pos;
}
void render(const GameState& state) {
system("cls"); // Windows clear screen
init_board();
board[state.food.y][state.food.x] = FOOD;
for (size_t i = 0; i < state.snake.size(); i++) {
const auto& segment = state.snake[i];
board[segment.y][segment.x] = (i == 0) ? SNAKE_HEAD : SNAKE_BODY;
}
std::cout << "=== SNAKE GAME ===\n";
std::cout << "Score: " << state.score << "\n";
std::cout << "Controls: WASD/Arrows to move, Q to quit, P to pause\n";
std::cout << "==================\n";
for (int y = 0; y < BOARD_HEIGHT; y++) {
std::cout << board[y] << "\n";
}
if (state.game_over) {
std::cout << "\n=== GAME OVER! ===\n";
std::cout << "Final Score: " << state.score << "\n";
std::cout << "Press R to restart or Q to quit.\n";
} else if (state.paused) {
std::cout << "\n=== PAUSED ===\n";
std::cout << "Press P to continue.\n";
}
}
bool kbhit() {
return _kbhit() != 0;
}
char get_char() {
return _getch();
}
void update_direction(GameState& state, Direction new_dir) {
Direction opposite = Direction::UP;
switch (state.dir) {
case Direction::UP: opposite = Direction::DOWN; break;
case Direction::DOWN: opposite = Direction::UP; break;
case Direction::LEFT: opposite = Direction::RIGHT; break;
case Direction::RIGHT: opposite = Direction::LEFT; break;
}
if (new_dir != opposite) {
state.next_dir = new_dir;
}
}
bool move_snake(GameState& state) {
state.dir = state.next_dir;
Position head = state.snake.front();
switch (state.dir) {
case Direction::UP: head.y--; break;
case Direction::DOWN: head.y++; break;
case Direction::LEFT: head.x--; break;
case Direction::RIGHT: head.x++; break;
}
// Check wall collision
if (head.x <= 0 || head.x >= BOARD_WIDTH - 1 ||
head.y <= 0 || head.y >= BOARD_HEIGHT - 1) {
return false;
}
// Check self collision (skip tail as it will move)
for (size_t i = 0; i < state.snake.size() - 1; i++) {
if (state.snake[i] == head) {
return false;
}
}
state.snake.push_front(head);
// Check food
if (head == state.food) {
state.score += 10;
place_food(state);
} else {
state.snake.pop_back();
}
return true;
}
void handle_input(GameState& state) {
if (kbhit()) {
char c = get_char();
if (state.game_over) {
if (c == 'r' || c == 'R') {
init_game(state);
}
return;
}
if (state.paused) {
if (c == 'p' || c == 'P') {
state.paused = false;
}
return;
}
switch (c) {
case 'w': case 'W': case 72: update_direction(state, Direction::UP); break;
case 's': case 'S': case 80: update_direction(state, Direction::DOWN); break;
case 'a': case 'A': case 75: update_direction(state, Direction::LEFT); break;
case 'd': case 'D': case 77: update_direction(state, Direction::RIGHT); break;
case 'q': case 'Q': state.game_over = true; state.score = -1; break;
case 'p': case 'P': state.paused = true; break;
}
}
}
int main() {
std::srand(static_cast<unsigned>(std::time(nullptr)));
GameState state;
init_game(state);
while (true) {
render(state);
if (!state.game_over && !state.paused) {
if (!move_snake(state)) {
state.game_over = true;
}
}
handle_input();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return 0;
}
+174
View File
@@ -0,0 +1,174 @@
#ifndef CST_AGENT_H
#define CST_AGENT_H
#include "ctl.h"
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
#define CST_AGENT_MAX_MSG 256
#define CST_AGENT_MAX_CONTENT (512 * 1024)
#define CST_AGENT_CTX_WINDOW 40 /* max non-system msgs sent to LLM */
typedef enum {
CST_ROLE_SYSTEM = 0,
CST_ROLE_USER,
CST_ROLE_ASSISTANT,
CST_ROLE_TOOL
} cst_role_t;
typedef struct {
cst_role_t role;
char *content;
char tool_call_id[CTL_MAX_ID]; /* for tool results */
char tool_name[CTL_MAX_NAME];
char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */
} cst_message_t;
typedef struct {
char workspace_root[4096];
char session_path[4096];
char api_base[512];
char api_key[512];
char model[128];
int headless;
int confirm_shell; /* confirm run_shell */
int confirm_write; /* confirm write_file / apply_patch */
int auto_approve; /* headless / CST_AUTO_APPROVE=1 */
} cst_agent_config_t;
typedef struct cst_session cst_session_t;
typedef struct cst_agent cst_agent_t;
/* --- sandbox --- */
int cst_sandbox_resolve(const char *workspace_root, const char *rel_or_abs,
char *out, size_t out_sz);
/* --- session (append-only JSONL) --- */
cst_session_t *cst_session_open(const char *path);
void cst_session_close(cst_session_t *s);
int cst_session_append(cst_session_t *s, const cst_message_t *msg);
int cst_session_count(const cst_session_t *s);
const cst_message_t *cst_session_get(const cst_session_t *s, int i);
const char *cst_session_path(const cst_session_t *s);
int cst_session_clear(cst_session_t *s, int keep_system);
int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n);
/* --- workspace .agent/sessions store (OpenCode-like) --- */
int cst_agent_store_ensure(const char *workspace);
int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz);
int cst_agent_store_new_id(char *id_out, size_t id_sz);
int cst_agent_store_path_for_id(const char *workspace, const char *id,
char *out, size_t out_sz);
int cst_agent_store_set_current(const char *workspace, const char *id);
int cst_agent_store_get_current(const char *workspace, char *id_out,
size_t id_sz);
int cst_agent_store_resolve_default(const char *workspace, char *path_out,
size_t path_sz, char *id_out,
size_t id_sz);
int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids);
/* --- tools --- */
typedef struct {
char *out;
size_t out_len;
int exit_code;
} cst_tool_result_t;
void cst_tool_result_free(cst_tool_result_t *r);
int cst_tool_read_file(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
int cst_tool_apply_patch(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
int cst_tool_dispatch(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r);
/* Build ctl_tool_call from native OpenAI tool_call fields. */
int cst_tool_call_from_native(ctl_tool_call_t *out, const char *id,
const char *name, const char *args_json);
/* --- LLM --- */
typedef void (*cst_llm_on_delta_fn)(const char *data, size_t len, void *ud);
typedef void (*cst_llm_on_native_tool_fn)(const char *id, const char *name,
const char *args_json, void *ud);
typedef struct {
const char *api_base;
const char *api_key;
const char *model;
const cst_message_t *msgs;
int nmsgs;
cst_llm_on_delta_fn on_delta;
cst_llm_on_native_tool_fn on_native_tool; /* fired once per completed call */
void *userdata;
int enable_tools; /* send tools[] schema */
int tool_choice_none; /* "tool_choice":"none" */
volatile int *cancel_flag; /* non-zero → abort curl */
} cst_llm_request_t;
int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, size_t err_sz);
/* Live UI / observability hooks for a turn */
typedef struct {
void (*on_status)(const char *msg, void *ud);
void (*on_assistant_delta)(const char *text, size_t len, void *ud);
void (*on_tool_event)(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud);
void (*on_think_delta)(const char *text, size_t len, void *ud);
/* Return 1=allow, 0=deny. NULL → use config auto_approve / defaults. */
int (*on_permission)(const char *tool_name, const char *detail, void *ud);
void *userdata;
} cst_agent_hooks_t;
/* --- agent loop --- */
cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg);
void cst_agent_destroy(cst_agent_t *a);
int cst_agent_run_turn(cst_agent_t *a, const char *user_text, char *err,
size_t err_sz);
int cst_agent_run_turn_ex(cst_agent_t *a, const char *user_text,
const cst_agent_hooks_t *hooks, char *err,
size_t err_sz);
cst_session_t *cst_agent_session(cst_agent_t *a);
const cst_agent_config_t *cst_agent_config(const cst_agent_t *a);
cst_agent_config_t *cst_agent_config_mut(cst_agent_t *a);
void cst_agent_request_cancel(cst_agent_t *a);
int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz);
int cst_agent_switch_session(cst_agent_t *a, const char *id);
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
const char *cst_agent_system_prompt(void);
/* Credentials: zenity + ~/.config/cst-agent/credentials (0600) */
int cst_creds_load_api_key(char *out, size_t out_sz);
int cst_creds_save_api_key(const char *key);
int cst_creds_prompt_api_key(char *out, size_t out_sz);
int cst_creds_ensure_api_key(char *out, size_t out_sz);
/* JSON baseline parser for experiments (buffer until valid object). */
typedef struct {
int ok;
char name[CTL_MAX_NAME];
char arguments_json[65536];
} cst_json_tool_t;
int cst_json_try_parse_tool(const char *buf, size_t len, cst_json_tool_t *out);
/* Built-in / CST_MODELS model picker list. Writes up to max_out names.
* Always includes `current` if non-empty and not already listed. */
int cst_models_list(const char *current, char out[][128], int max_out);
int cst_tui_run(cst_agent_t *agent);
#ifdef __cplusplus
}
#endif
#endif /* CST_AGENT_H */
+98
View File
@@ -0,0 +1,98 @@
#ifndef CTL_H
#define CTL_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#ifndef CTL_MAX_LINE
#define CTL_MAX_LINE 4096
#endif
#ifndef CTL_MAX_ARGS
#define CTL_MAX_ARGS 16
#endif
#ifndef CTL_MAX_ARG_BYTES
#define CTL_MAX_ARG_BYTES (1024 * 1024)
#endif
#ifndef CTL_MAX_ID
#define CTL_MAX_ID 64
#endif
#ifndef CTL_MAX_NAME
#define CTL_MAX_NAME 64
#endif
#ifndef CTL_MAX_KEY
#define CTL_MAX_KEY 64
#endif
typedef enum {
CTL_OK = 0,
CTL_ERR_CONTRACT = 1, /* left L0_tool */
CTL_ERR_NOMEM = 2,
CTL_ERR_LIMIT = 3
} ctl_status_t;
typedef enum {
CTL_EV_NONE = 0,
CTL_EV_CALL_OPEN, /* id+name confirmed */
CTL_EV_ARG, /* one arg fully confirmed */
CTL_EV_CALL_READY, /* end confirmed — safe to execute */
CTL_EV_TEXT, /* non-CTL confirmed text (outside call) */
CTL_EV_ERROR
} ctl_event_type_t;
typedef struct {
char key[CTL_MAX_KEY];
char *value;
size_t value_len;
} ctl_arg_t;
typedef struct {
char id[CTL_MAX_ID];
char name[CTL_MAX_NAME];
ctl_arg_t args[CTL_MAX_ARGS];
int nargs;
} ctl_tool_call_t;
typedef struct {
ctl_event_type_t type;
const ctl_tool_call_t *call; /* valid for CALL_* / ARG */
const char *text; /* for TEXT */
size_t text_len;
const char *error; /* for ERROR */
} ctl_event_t;
typedef void (*ctl_on_event_fn)(const ctl_event_t *ev, void *userdata);
typedef struct ctl_parser ctl_parser_t;
ctl_parser_t *ctl_parser_create(ctl_on_event_fn on_event, void *userdata);
void ctl_parser_destroy(ctl_parser_t *p);
/* Feed UTF-8 bytes. Emits only confirmed events. */
ctl_status_t ctl_parser_feed(ctl_parser_t *p, const char *data, size_t len);
/* EOF: flush; incomplete call => ERROR (no execute). */
ctl_status_t ctl_parser_finish(ctl_parser_t *p);
/* Bytes currently pending (unconfirmed). */
size_t ctl_parser_pending(const ctl_parser_t *p);
/* Peak pending observed (for experiments). */
size_t ctl_parser_pending_peak(const ctl_parser_t *p);
/* Reset for a new assistant turn. */
void ctl_parser_reset(ctl_parser_t *p);
#ifdef __cplusplus
}
#endif
#endif /* CTL_H */
+51
View File
@@ -0,0 +1,51 @@
#ifndef MM_TOOL_H
#define MM_TOOL_H
#include "ctl.h"
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Streaming CST scanner for MiniMax native tool XML:
*
* <minimax:tool_call>
* <invoke name="read_file">
* <parameter name="path">README.md</parameter>
* </invoke>
* </minimax:tool_call>
*
* Also accepts the dialect seen in the wild:
* <minimax:tool_call>
* invoke read_file
* <arg name="path">./README.md</arg>
* </minimax:tool_call>
*
* Confirmation: nothing executes until </minimax:tool_call>.
*/
typedef void (*mm_on_event_fn)(const ctl_event_t *ev, void *userdata);
typedef struct mm_scan mm_scan_t;
mm_scan_t *mm_scan_create(mm_on_event_fn on_event, void *userdata);
void mm_scan_destroy(mm_scan_t *s);
void mm_scan_reset(mm_scan_t *s);
/* Feed bytes. Emits TEXT for confirmed non-tool regions and CALL_* for
* completed tool blocks. Incomplete tool blocks stay pending. */
int mm_scan_feed(mm_scan_t *s, const char *data, size_t len);
/* Flush: incomplete open tool block => emit remaining as TEXT (no execute). */
int mm_scan_finish(mm_scan_t *s);
size_t mm_scan_pending(const mm_scan_t *s);
#ifdef __cplusplus
}
#endif
#endif
+68
View File
@@ -0,0 +1,68 @@
#ifndef CST_OS_COMPAT_H
#define CST_OS_COMPAT_H
#include <stddef.h>
#include <time.h>
#ifdef __cplusplus
extern "C" {
#endif
#ifndef OS_PATH_MAX
#define OS_PATH_MAX 4096
#endif
/* --- path --- */
int os_path_is_abs(const char *p);
/* Join with native separator; out always uses '/' on Unix and '\\' on Win for
* the joiner, but os_path_norm_slashes converts to '/' for comparisons. */
int os_path_join(char *out, size_t out_sz, const char *a, const char *b);
void os_path_norm_slashes(char *p);
int os_realpath(const char *path, char *out, size_t out_sz);
/* True if path is under root (after realpath/norm). Trailing slash rules apply. */
int os_path_under(const char *root, const char *path);
/* --- dirs / config --- */
int os_mkdir(const char *path, int mode);
int os_config_dir(char *out, size_t out_sz);
int os_temp_dir(char *out, size_t out_sz);
/* Create unique temp file; writes path into out; returns fd or -1. Caller closes. */
int os_mkstemp(char *out, size_t out_sz, const char *prefix);
/* Directory containing the running executable (for bundled busybox).
* Call os_set_prog_path(argv[0]) early on Unix/Termux as fallback. */
void os_set_prog_path(const char *argv0);
int os_exe_dir(char *out, size_t out_sz);
/* Resolve shell interpreter path into out.
* Order: CST_SHELL → <exe_dir>/busybox[.exe] → /bin/sh (Unix). */
int os_resolve_shell(char *out, size_t out_sz);
/* --- process --- */
typedef struct {
char *out;
size_t out_len;
int exit_code;
} os_run_result_t;
void os_run_result_free(os_run_result_t *r);
/* Run argv[0..] (NULL-terminated) with cwd; capture stdout+stderr.
* max_out caps captured bytes (0 = default 512KiB). */
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
os_run_result_t *r);
/* Convenience: shell -c cmd via os_resolve_shell. */
int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out,
os_run_result_t *r);
/* --- misc --- */
void os_localtime(const time_t *t, struct tm *out);
int os_getpid(void);
int os_chmod_private(const char *path); /* best-effort 0600; no-op/ok on Win */
#ifdef __cplusplus
}
#endif
#endif /* CST_OS_COMPAT_H */
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# One-shot: clone (optional) + build CST-Agent on Linux.
set -euo pipefail
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
DIR="${1:-}"
if [[ -z "$DIR" ]]; then
if [[ -f Makefile && -d src ]]; then
DIR="$(pwd)"
else
DIR="CST-agent"
fi
fi
if [[ ! -f "$DIR/Makefile" ]]; then
echo "==> git clone $REPO_URL -> $DIR"
git clone "$REPO_URL" "$DIR"
fi
cd "$DIR"
echo "==> deps hint: gcc/clang make cmake libcurl ncursesw (zenity optional)"
if command -v pacman >/dev/null 2>&1; then
echo " Arch: sudo pacman -S --needed base-devel cmake curl ncurses"
elif command -v apt-get >/dev/null 2>&1; then
echo " Debian: sudo apt install build-essential cmake libcurl4-openssl-dev libncurses-dev"
fi
echo "==> make"
make -j"$(nproc 2>/dev/null || echo 4)"
make check || true
echo
echo "OK: ./cst-agent"
echo "OpenAI-compatible env (see docs/env_openai.md):"
echo " export OPENAI_API_KEY=sk-..."
echo " export OPENAI_BASE_URL=https://api.openai.com/v1"
echo " export OPENAI_MODEL=gpt-4.1-mini"
echo " ./cst-agent --workspace ."
echo "Or MiniMax default: export CST_API_KEY=... && ./cst-agent --workspace ."
+31
View File
@@ -0,0 +1,31 @@
#!/data/data/com.termux/files/usr/bin/bash
# One-shot: clone + build CST-Agent on Android Termux.
set -euo pipefail
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
DIR="${1:-CST-agent}"
echo "==> Termux packages"
pkg install -y git clang make cmake libcurl ncurses-ui-libs 2>/dev/null \
|| pkg install -y git clang make cmake curl ncurses
if [[ ! -f "$DIR/Makefile" ]]; then
echo "==> git clone $REPO_URL -> $DIR"
git clone "$REPO_URL" "$DIR"
fi
cd "$DIR"
echo "==> cmake build (TUI)"
cmake -B build -DCMAKE_C_COMPILER=clang -DCST_BUILD_TUI=ON
cmake --build build -j"$(nproc 2>/dev/null || echo 4)"
# Also keep Makefile binary path convenience
cp -f build/cst-agent ./cst-agent 2>/dev/null || true
echo
echo "OK: ./cst-agent (or ./build/cst-agent)"
echo "OpenAI-compatible:"
echo " export OPENAI_API_KEY=sk-..."
echo " export OPENAI_BASE_URL=https://api.openai.com/v1"
echo " export OPENAI_MODEL=gpt-4.1-mini"
echo " ./cst-agent --workspace \$HOME"
echo "Docs: docs/env_openai.md"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# One-shot on a Linux host: cross-build Windows x86_64 release (MinGW).
# Output: dist/windows-x86_64/ — copy that folder to a Windows machine.
set -euo pipefail
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
DIR="${1:-}"
if [[ -z "$DIR" ]]; then
if [[ -f CMakeLists.txt && -d src ]]; then
DIR="$(pwd)"
else
DIR="CST-agent"
fi
fi
if [[ ! -f "$DIR/CMakeLists.txt" ]]; then
echo "==> git clone $REPO_URL -> $DIR"
git clone "$REPO_URL" "$DIR"
fi
cd "$DIR"
need() { command -v "$1" >/dev/null || { echo "missing $1 (install mingw-w64 gcc cmake git)" >&2; exit 1; }; }
need git; need cmake; need x86_64-w64-mingw32-gcc
echo "==> third-party (curl Schannel + PDCurses + busybox-w32)"
chmod +x scripts/*.sh 2>/dev/null || true
./scripts/build_third_party_mingw.sh all
echo "==> cmake Windows TUI"
cmake -B build-win-tui \
-DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=ON \
-DCMAKE_PREFIX_PATH="$PWD/third_party/prefix-mingw" \
-DPDCurses_ROOT="$PWD/third_party/prefix-mingw"
cmake --build build-win-tui -j"$(nproc 2>/dev/null || echo 4)"
echo "==> package"
./scripts/package_windows.sh
echo
echo "OK: dist/windows-x86_64/"
echo "Copy that folder to Windows, then:"
echo " set OPENAI_API_KEY=sk-..."
echo " set OPENAI_BASE_URL=https://api.openai.com/v1"
echo " set OPENAI_MODEL=gpt-4.1-mini"
echo " cst-agent.exe --workspace ."
echo "See docs/env_openai.md and docs/README-windows.md"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Build third-party deps for Windows MinGW cross + optional Unix busybox.
# Sources:
# busybox-w32: https://github.com/rmyorston/busybox-w32.git
# busybox: https://github.com/mirror/busybox.git
# curl: https://github.com/curl/curl.git (Windows via Schannel)
# PDCurses: https://github.com/wmcbrine/PDCurses.git
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TP="$ROOT/third_party"
HOST="${MINGW_HOST:-x86_64-w64-mingw32}"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
mkdir -p "$TP"
need_cmd() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
need_cmd git
need_cmd make
need_cmd "${HOST}-gcc"
clone_or_update() {
local url="$1" dir="$2"
if [[ -d "$dir/.git" ]]; then
git -C "$dir" fetch --depth 1 origin 2>/dev/null || true
git -C "$dir" pull --ff-only 2>/dev/null || true
else
git clone --depth 1 "$url" "$dir"
fi
}
# --- curl (static, Schannel) ---
build_curl_mingw() {
clone_or_update https://github.com/curl/curl.git "$TP/curl"
cd "$TP/curl"
if [[ ! -f configure ]]; then
autoreconf -fi 2>/dev/null || true
fi
# Prefer cmake for curl modern tree
rm -rf build-mingw
cmake -B build-mingw -G "Unix Makefiles" \
-DCMAKE_SYSTEM_NAME=Windows \
-DCMAKE_C_COMPILER="${HOST}-gcc" \
-DCMAKE_RC_COMPILER="${HOST}-windres" \
-DCMAKE_INSTALL_PREFIX="$TP/prefix-mingw" \
-DBUILD_SHARED_LIBS=OFF \
-DCURL_USE_SCHANNEL=ON \
-DCURL_USE_OPENSSL=OFF \
-DCURL_DISABLE_LDAP=ON \
-DCURL_USE_LIBPSL=OFF \
-DCURL_ZLIB=OFF \
-DCURL_BROTLI=OFF \
-DCURL_ZSTD=OFF \
-DUSE_NGHTTP2=OFF \
-DBUILD_CURL_EXE=OFF \
-DBUILD_TESTING=OFF \
-DHTTP_ONLY=OFF
cmake --build build-mingw -j"$JOBS"
cmake --install build-mingw
echo "curl installed -> $TP/prefix-mingw"
}
# --- PDCurses ---
build_pdcurses_mingw() {
clone_or_update https://github.com/wmcbrine/PDCurses.git "$TP/PDCurses"
cd "$TP/PDCurses/wincon"
make -f Makefile clean 2>/dev/null || true
make -f Makefile \
CC="${HOST}-gcc" \
LINK="${HOST}-gcc" \
CFLAGS="-O2 -I.. -DPDC_WIDE -DPDC_FORCE_UTF8" \
LIBEXE="${HOST}-ar" \
LIBFLAGS=rcv \
pdcurses.a
mkdir -p "$TP/prefix-mingw/include" "$TP/prefix-mingw/lib"
cp -f ../curses.h "$TP/prefix-mingw/include/"
[[ -f ../panel.h ]] && cp -f ../panel.h "$TP/prefix-mingw/include/" || true
cp -f pdcurses.a "$TP/prefix-mingw/lib/libpdcurses.a"
# Also install as curses for find_library
cp -f pdcurses.a "$TP/prefix-mingw/lib/libcurses.a"
echo "PDCurses -> $TP/prefix-mingw"
}
# --- busybox-w32 ---
build_busybox_w32() {
clone_or_update https://github.com/rmyorston/busybox-w32.git "$TP/busybox-w32"
cd "$TP/busybox-w32"
# configs: try mingw64 defconfig
if [[ -f configs/mingw64_defconfig ]]; then
make mingw64_defconfig
elif [[ -f configs/mingw_defconfig ]]; then
make mingw_defconfig
else
make defconfig
fi
# Cross compile
make -j"$JOBS" CROSS_COMPILE="${HOST}-" \
CONFIG_EXTRA_CFLAGS="-O2" \
busybox.exe 2>&1 || make -j"$JOBS" CROSS_COMPILE="${HOST}-"
mkdir -p "$TP/busybox"
if [[ -f busybox.exe ]]; then
cp -f busybox.exe "$TP/busybox/busybox.exe"
else
find . -name 'busybox.exe' | head -1 | xargs -I{} cp -f {} "$TP/busybox/busybox.exe"
fi
cat > "$TP/busybox/LICENSE.busybox" << 'EOF'
BusyBox is GPLv2. Source: https://github.com/rmyorston/busybox-w32
Upstream BusyBox: https://github.com/mirror/busybox
EOF
ls -la "$TP/busybox/busybox.exe"
}
# --- Unix busybox (optional, for Linux/Termux side-by-side bundle) ---
build_busybox_unix() {
clone_or_update https://github.com/mirror/busybox.git "$TP/busybox-src"
cd "$TP/busybox-src"
make defconfig
# Keep applets reasonable; enable ash + diff
make -j"$JOBS" busybox
mkdir -p "$TP/busybox"
cp -f busybox "$TP/busybox/busybox"
echo "unix busybox -> $TP/busybox/busybox"
}
TARGETS="${1:-all}"
case "$TARGETS" in
curl) build_curl_mingw ;;
pdcurses) build_pdcurses_mingw ;;
busybox-w32) build_busybox_w32 ;;
busybox-unix) build_busybox_unix ;;
all)
build_curl_mingw
build_pdcurses_mingw
build_busybox_w32
;;
*)
echo "usage: $0 [all|curl|pdcurses|busybox-w32|busybox-unix]"
exit 1
;;
esac
echo "DONE"
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# E2E: copy mini_calc → temp workspace → optional cst-agent headless → make test.
# Usage:
# ./scripts/e2e_mini_calc.sh # run agent if credentials exist
# ./scripts/e2e_mini_calc.sh --baseline-only # only prove fixture starts RED then skip agent
# SKIP_AGENT=1 ./scripts/e2e_mini_calc.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FIX="$ROOT/fixtures/mini_calc"
OUT="$FIX/out"
BASELINE_ONLY=0
for a in "$@"; do
case "$a" in
--baseline-only) BASELINE_ONLY=1 ;;
esac
done
mkdir -p "$OUT"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
WORKDIR="$(mktemp -d /tmp/cst-mini-calc.XXXXXX)"
RESULT="$OUT/result-$STAMP.txt"
SESSION_COPY="$OUT/session-$STAMP.jsonl"
cleanup() { rm -rf "$WORKDIR"; }
trap cleanup EXIT
echo "== mini_calc e2e ==" | tee "$RESULT"
echo "workdir=$WORKDIR" | tee -a "$RESULT"
echo "stamp=$STAMP" | tee -a "$RESULT"
# Fresh copy (no .agent from previous runs)
cp -a "$FIX/." "$WORKDIR/"
rm -rf "$WORKDIR/out" "$WORKDIR/.agent"
chmod +x "$WORKDIR/tests/smoke.sh"
cd "$WORKDIR"
make clean >/dev/null 2>&1 || true
make
echo "-- baseline make test (expect FAIL before agent) --" | tee -a "$RESULT"
set +e
make test >"$OUT/baseline-$STAMP.log" 2>&1
base_rc=$?
set -e
echo "baseline_rc=$base_rc" | tee -a "$RESULT"
if [[ "$base_rc" -eq 0 ]]; then
echo "UNEXPECTED: fixture already passes; bugs may have been fixed in source." | tee -a "$RESULT"
echo "outcome=unexpected_green_fixture" | tee -a "$RESULT"
exit 2
fi
echo "baseline: RED (as expected)" | tee -a "$RESULT"
if [[ "$BASELINE_ONLY" -eq 1 || "${SKIP_AGENT:-0}" == "1" ]]; then
echo "outcome=baseline_ok_agent_skipped" | tee -a "$RESULT"
echo "SKIP agent step (--baseline-only or SKIP_AGENT=1)"
exit 0
fi
# Need API key for agent step
has_key=0
if [[ -n "${CST_API_KEY:-}" || -n "${MINIMAX_API_KEY:-}" ]]; then
has_key=1
elif [[ -f "$HOME/.config/cst-agent/credentials" ]]; then
has_key=1
fi
if [[ "$has_key" -eq 0 ]]; then
echo "outcome=baseline_ok_no_api_key" | tee -a "$RESULT"
echo "No API key; agent step skipped. Set CST_API_KEY or save credentials."
exit 0
fi
cd "$ROOT"
make -s
mkdir -p "$WORKDIR/.agent"
PROMPT="$(cat "$FIX/TASK.md")"
export CST_AUTO_APPROVE=1
echo "-- cst-agent headless --" | tee -a "$RESULT"
set +e
"$ROOT/cst-agent" --headless --workspace "$WORKDIR" --session "$WORKDIR/.agent/e2e.jsonl" \
--prompt "$PROMPT" >"$OUT/agent-$STAMP.log" 2>&1
agent_rc=$?
set -e
echo "agent_rc=$agent_rc" | tee -a "$RESULT"
if [[ -f "$WORKDIR/.agent/e2e.jsonl" ]]; then
cp -f "$WORKDIR/.agent/e2e.jsonl" "$SESSION_COPY"
echo "session_copy=$SESSION_COPY" | tee -a "$RESULT"
fi
cd "$WORKDIR"
set +e
make test >"$OUT/after-$STAMP.log" 2>&1
test_rc=$?
set -e
echo "after_test_rc=$test_rc" | tee -a "$RESULT"
# Classify
changed=0
if ! diff -q "$FIX/calc.c" "$WORKDIR/calc.c" >/dev/null 2>&1; then
changed=1
fi
echo "calc_c_changed=$changed" | tee -a "$RESULT"
if [[ "$test_rc" -eq 0 ]]; then
echo "outcome=pass" | tee -a "$RESULT"
exit 0
fi
if [[ "$changed" -eq 1 ]]; then
echo "outcome=partial" | tee -a "$RESULT"
exit 1
fi
echo "outcome=fail" | tee -a "$RESULT"
exit 1
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Fetch busybox-w32 into third_party/busybox/busybox.exe (Linux/CI host).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEST="$ROOT/third_party/busybox"
mkdir -p "$DEST"
OUT="$DEST/busybox.exe"
# Override with BUSYBOX_W32_URL if the default 404s / TLS fails.
URLS=(
"${BUSYBOX_W32_URL:-}"
"https://frippery.org/files/busybox/busybox-w64-FRP-5007-g82accfc19.exe"
"https://frippery.org/files/busybox/busybox.exe"
)
ok=0
for URL in "${URLS[@]}"; do
[[ -z "$URL" ]] && continue
echo "Downloading $URL -> $OUT"
if curl -fsSL -L --connect-timeout 20 -o "$OUT" "$URL"; then
ok=1
break
fi
echo "failed: $URL" >&2
done
cat > "$DEST/LICENSE.busybox" << 'EOF'
BusyBox is licensed under GPLv2. Obtain the corresponding source from the
upstream busybox-w32 / BusyBox project when redistributing the binary.
See: https://www.busybox.net/ https://frippery.org/busybox/
EOF
if [[ "$ok" -ne 1 ]]; then
cat >&2 <<'EOF'
Could not download busybox-w32 automatically.
Manual steps:
1. Visit https://frippery.org/busybox/ or busybox-w32 releases
2. Save the 64-bit binary as agent/third_party/busybox/busybox.exe
3. Copy it next to cst-agent.exe on Windows releases
EOF
exit 1
fi
chmod +x "$OUT" || true
file "$OUT" || true
ls -la "$OUT"
echo "OK: place this next to cst-agent.exe on Windows releases."
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Assemble dist/windows-x86_64 from MinGW TUI build + busybox.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REL="$ROOT/dist/windows-x86_64"
EXE="$ROOT/build-win-tui/cst-agent.exe"
[[ -f "$EXE" ]] || EXE="$ROOT/build-win/cst-agent.exe"
BB="$ROOT/third_party/busybox/busybox.exe"
DLL="/usr/x86_64-w64-mingw32/bin/libwinpthread-1.dll"
mkdir -p "$REL"
cp -f "$EXE" "$REL/cst-agent.exe"
cp -f "$BB" "$REL/busybox.exe"
cp -f "$ROOT/third_party/busybox/LICENSE.busybox" "$REL/"
cp -f "$ROOT/docs/README-windows.md" "$REL/"
[[ -f "$DLL" ]] && cp -f "$DLL" "$REL/"
ls -la "$REL"
file "$REL/cst-agent.exe"
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Replay starvation experiment (CTL vs JSON-buffer).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
make check
echo
echo "See docs/theory_tool_cst.md for the claim under test."
+27
View File
@@ -0,0 +1,27 @@
@echo off
REM Run ON Windows after copying dist/windows-x86_64 (or a full git checkout + MSYS2 build).
REM This script only documents env + launch; prefer bootstrap_windows.sh on Linux for cross-build.
setlocal
if "%OPENAI_API_KEY%"=="" if "%CST_API_KEY%"=="" (
echo Set OPENAI_API_KEY or CST_API_KEY first. See docs\env_openai.md
echo Example:
echo set OPENAI_API_KEY=sk-...
echo set OPENAI_BASE_URL=https://api.openai.com/v1
echo set OPENAI_MODEL=gpt-4.1-mini
exit /b 1
)
if not exist cst-agent.exe (
echo cst-agent.exe not found in %CD%
echo Copy dist\windows-x86_64\* here, or build with MSYS2/MinGW.
exit /b 1
)
if not exist busybox.exe (
echo WARNING: busybox.exe missing — run_shell may fail. Place busybox.exe beside cst-agent.exe
)
echo Launching CST-Agent...
cst-agent.exe --workspace .
endlocal
+1182
View File
File diff suppressed because it is too large Load Diff
+189
View File
@@ -0,0 +1,189 @@
#define _DEFAULT_SOURCE
#include "cst_agent.h"
#include "os_compat.h"
#include <dirent.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#ifdef _WIN32
#include <io.h>
#define OS_ACCESS _access
#define OS_F_OK 0
#else
#include <unistd.h>
#define OS_ACCESS access
#define OS_F_OK F_OK
#endif
int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz) {
if (!workspace || !out || out_sz < 16)
return -1;
return os_path_join(out, out_sz, workspace, ".agent/sessions");
}
int cst_agent_store_ensure(const char *workspace) {
char base[OS_PATH_MAX];
char sess[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, sess, sizeof(sess)) != 0)
return -1;
if (os_path_join(base, sizeof(base), workspace, ".agent") != 0)
return -1;
if (os_mkdir(base, 0755) != 0)
return -1;
if (os_mkdir(sess, 0755) != 0)
return -1;
return 0;
}
static void make_id(char *out, size_t n) {
time_t now = time(NULL);
struct tm tm;
os_localtime(&now, &tm);
unsigned r = (unsigned)(os_getpid() ^ (unsigned)now);
snprintf(out, n, "%04d%02d%02d-%02d%02d%02d-%04x", tm.tm_year + 1900,
tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec,
r & 0xffff);
}
int cst_agent_store_new_id(char *id_out, size_t id_sz) {
if (!id_out || id_sz < 24)
return -1;
make_id(id_out, id_sz);
return 0;
}
int cst_agent_store_path_for_id(const char *workspace, const char *id,
char *out, size_t out_sz) {
if (!workspace || !id || !out)
return -1;
/* Accept bare id or full path. */
if (strchr(id, '/') || strchr(id, '\\')) {
snprintf(out, out_sz, "%s", id);
return 0;
}
char dir[OS_PATH_MAX];
char leaf[160];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
return -1;
snprintf(leaf, sizeof(leaf), "%s.jsonl", id);
return os_path_join(out, out_sz, dir, leaf);
}
int cst_agent_store_set_current(const char *workspace, const char *id) {
if (cst_agent_store_ensure(workspace) != 0)
return -1;
char dir[OS_PATH_MAX];
char path[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
return -1;
if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0)
return -1;
FILE *fp = fopen(path, "w");
if (!fp)
return -1;
fprintf(fp, "%s\n", id);
fclose(fp);
return 0;
}
int cst_agent_store_get_current(const char *workspace, char *id_out,
size_t id_sz) {
char dir[OS_PATH_MAX];
char path[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
return -1;
if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0)
return -1;
FILE *fp = fopen(path, "r");
if (!fp)
return -1;
if (!fgets(id_out, (int)id_sz, fp)) {
fclose(fp);
return -1;
}
fclose(fp);
size_t n = strlen(id_out);
while (n > 0 && (id_out[n - 1] == '\n' || id_out[n - 1] == '\r'))
id_out[--n] = 0;
return id_out[0] ? 0 : -1;
}
int cst_agent_store_resolve_default(const char *workspace, char *path_out,
size_t path_sz, char *id_out,
size_t id_sz) {
if (cst_agent_store_ensure(workspace) != 0)
return -1;
char id[128];
if (cst_agent_store_get_current(workspace, id, sizeof(id)) == 0) {
char p[OS_PATH_MAX];
if (cst_agent_store_path_for_id(workspace, id, p, sizeof(p)) == 0 &&
OS_ACCESS(p, OS_F_OK) == 0) {
snprintf(path_out, path_sz, "%s", p);
if (id_out && id_sz)
snprintf(id_out, id_sz, "%s", id);
return 0;
}
}
if (cst_agent_store_new_id(id, sizeof(id)) != 0)
return -1;
if (cst_agent_store_path_for_id(workspace, id, path_out, path_sz) != 0)
return -1;
cst_agent_store_set_current(workspace, id);
if (id_out && id_sz)
snprintf(id_out, id_sz, "%s", id);
return 0;
}
int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
if (!workspace || !ids || max_ids <= 0)
return 0;
char dir[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
return 0;
DIR *d = opendir(dir);
if (!d)
return 0;
typedef struct {
char id[128];
time_t mtime;
} ent_t;
ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t));
int n = 0, cap = max_ids * 4;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
size_t len = strlen(de->d_name);
if (len < 7 || strcmp(de->d_name + len - 6, ".jsonl") != 0)
continue;
if (n >= cap)
break;
snprintf(ents[n].id, sizeof(ents[n].id), "%.*s", (int)(len - 6),
de->d_name);
char full[OS_PATH_MAX];
if (os_path_join(full, sizeof(full), dir, de->d_name) != 0)
continue;
struct stat st;
ents[n].mtime = (stat(full, &st) == 0) ? st.st_mtime : 0;
n++;
}
closedir(d);
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (ents[j].mtime > ents[i].mtime) {
ent_t tmp = ents[i];
ents[i] = ents[j];
ents[j] = tmp;
}
}
}
int out_n = n < max_ids ? n : max_ids;
for (int i = 0; i < out_n; i++)
snprintf(ids[i], 128, "%s", ents[i].id);
free(ents);
return out_n;
}
+140
View File
@@ -0,0 +1,140 @@
#define _DEFAULT_SOURCE
#include "cst_agent.h"
#include "os_compat.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <io.h>
#else
#include <unistd.h>
#endif
/* Config dir via os_compat: ~/.config/cst-agent or %APPDATA%\cst-agent */
static int credentials_path(char *out, size_t n) {
char dir[OS_PATH_MAX];
if (os_config_dir(dir, sizeof(dir)) != 0)
return -1;
return os_path_join(out, n, dir, "credentials");
}
int cst_creds_load_api_key(char *out, size_t out_sz) {
if (!out || out_sz < 2)
return -1;
out[0] = 0;
char path[OS_PATH_MAX];
if (credentials_path(path, sizeof(path)) != 0)
return -1;
FILE *fp = fopen(path, "r");
if (!fp)
return -1;
char line[1024];
while (fgets(line, sizeof(line), fp)) {
if (strncmp(line, "api_key=", 8) != 0)
continue;
char *v = line + 8;
size_t len = strlen(v);
while (len > 0 && (v[len - 1] == '\n' || v[len - 1] == '\r'))
v[--len] = 0;
if (len == 0 || len + 1 > out_sz) {
fclose(fp);
return -1;
}
memcpy(out, v, len + 1);
fclose(fp);
return 0;
}
fclose(fp);
return -1;
}
int cst_creds_save_api_key(const char *key) {
if (!key || !key[0])
return -1;
char dir[OS_PATH_MAX], path[OS_PATH_MAX];
if (os_config_dir(dir, sizeof(dir)) != 0)
return -1;
if (os_mkdir(dir, 0700) != 0)
return -1;
if (credentials_path(path, sizeof(path)) != 0)
return -1;
FILE *fp = fopen(path, "w");
if (!fp)
return -1;
fprintf(fp, "api_key=%s\n", key);
fprintf(fp, "api_base=https://api.minimax.cn/v1\n");
fprintf(fp, "model=MiniMax-M2.7\n");
fclose(fp);
(void)os_chmod_private(path);
return 0;
}
/* Zenity on Unix; stdin prompt elsewhere / when zenity missing. */
int cst_creds_prompt_api_key(char *out, size_t out_sz) {
if (!out || out_sz < 2)
return -1;
out[0] = 0;
#ifndef _WIN32
FILE *fp = popen(
"zenity --password --title='CST-Agent / MiniMax' "
"--text='请输入 MiniMax API Key(将保存到配置目录 credentials)' "
"2>/dev/null",
"r");
if (fp) {
if (fgets(out, (int)out_sz, fp)) {
int st = pclose(fp);
size_t len = strlen(out);
while (len > 0 && (out[len - 1] == '\n' || out[len - 1] == '\r'))
out[--len] = 0;
if (st == 0 && len > 0)
return 0;
} else {
pclose(fp);
}
out[0] = 0;
}
#endif
/* Fallback: console prompt (headless / Windows / no zenity). */
fprintf(stderr, "CST-Agent: enter MiniMax API Key (saved to config dir): ");
fflush(stderr);
if (!fgets(out, (int)out_sz, stdin)) {
out[0] = 0;
return -1;
}
size_t len = strlen(out);
while (len > 0 && (out[len - 1] == '\n' || out[len - 1] == '\r'))
out[--len] = 0;
if (len == 0) {
out[0] = 0;
return -1;
}
return 0;
}
int cst_creds_ensure_api_key(char *out, size_t out_sz) {
const char *env = getenv("CST_API_KEY");
if (!env || !env[0])
env = getenv("MINIMAX_API_KEY");
if (!env || !env[0])
env = getenv("OPENAI_API_KEY");
if (env && env[0]) {
snprintf(out, out_sz, "%s", env);
return 0;
}
if (cst_creds_load_api_key(out, out_sz) == 0 && out[0])
return 0;
if (cst_creds_prompt_api_key(out, out_sz) != 0)
return -1;
if (cst_creds_save_api_key(out) != 0)
fprintf(stderr, "warning: failed to save credentials file\n");
else
fprintf(stderr, "saved API key to config directory\n");
return 0;
}
+419
View File
@@ -0,0 +1,419 @@
#include "ctl.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef enum {
ST_TEXT = 0, /* outside a call; accumulate text lines */
ST_LINE, /* collecting a line (call/arg/end or text) */
ST_ARG_BODY, /* reading nbytes of arg body */
ST_ERROR
} ctl_state_t;
struct ctl_parser {
ctl_on_event_fn on_event;
void *userdata;
ctl_state_t state;
char line[CTL_MAX_LINE];
size_t line_len;
ctl_tool_call_t call;
int in_call;
/* current arg being filled */
char arg_key[CTL_MAX_KEY];
char *arg_buf;
size_t arg_cap;
size_t arg_got;
size_t arg_need;
size_t pending;
size_t pending_peak;
char errbuf[256];
};
static void bump_pending(ctl_parser_t *p, size_t n) {
p->pending = n;
if (n > p->pending_peak)
p->pending_peak = n;
}
static void emit(ctl_parser_t *p, ctl_event_t *ev) {
if (p->on_event)
p->on_event(ev, p->userdata);
}
static void fail(ctl_parser_t *p, const char *msg) {
snprintf(p->errbuf, sizeof(p->errbuf), "%s", msg);
p->state = ST_ERROR;
ctl_event_t ev = {0};
ev.type = CTL_EV_ERROR;
ev.error = p->errbuf;
emit(p, &ev);
}
/* free args in current call */
static void clear_call_args(ctl_tool_call_t *c) {
for (int i = 0; i < c->nargs; i++) {
free(c->args[i].value);
c->args[i].value = NULL;
c->args[i].value_len = 0;
}
c->nargs = 0;
c->id[0] = 0;
c->name[0] = 0;
}
static void reset_arg_body(ctl_parser_t *p) {
free(p->arg_buf);
p->arg_buf = NULL;
p->arg_cap = 0;
p->arg_got = 0;
p->arg_need = 0;
p->arg_key[0] = 0;
}
ctl_parser_t *ctl_parser_create(ctl_on_event_fn on_event, void *userdata) {
ctl_parser_t *p = calloc(1, sizeof(*p));
if (!p)
return NULL;
p->on_event = on_event;
p->userdata = userdata;
p->state = ST_TEXT;
return p;
}
void ctl_parser_destroy(ctl_parser_t *p) {
if (!p)
return;
clear_call_args(&p->call);
reset_arg_body(p);
free(p);
}
void ctl_parser_reset(ctl_parser_t *p) {
if (!p)
return;
clear_call_args(&p->call);
reset_arg_body(p);
p->state = ST_TEXT;
p->line_len = 0;
p->in_call = 0;
p->pending = 0;
/* keep pending_peak for experiment */
}
size_t ctl_parser_pending(const ctl_parser_t *p) {
return p ? p->pending : 0;
}
size_t ctl_parser_pending_peak(const ctl_parser_t *p) {
return p ? p->pending_peak : 0;
}
static int starts_with(const char *s, const char *pfx) {
return strncmp(s, pfx, strlen(pfx)) == 0;
}
static int parse_call_line(ctl_parser_t *p, const char *line) {
/* call <id> <name> */
const char *s = line + 5;
while (*s == ' ' || *s == '\t')
s++;
if (!*s)
return -1;
const char *sp = strchr(s, ' ');
if (!sp)
return -1;
size_t idlen = (size_t)(sp - s);
if (idlen == 0 || idlen >= CTL_MAX_ID)
return -1;
while (*sp == ' ' || *sp == '\t')
sp++;
if (!*sp)
return -1;
size_t nlen = strlen(sp);
if (nlen == 0 || nlen >= CTL_MAX_NAME)
return -1;
for (size_t i = 0; i < nlen; i++) {
if (!(isalnum((unsigned char)sp[i]) || sp[i] == '_' || sp[i] == '-'))
return -1;
}
clear_call_args(&p->call);
memcpy(p->call.id, s, idlen);
p->call.id[idlen] = 0;
memcpy(p->call.name, sp, nlen);
p->call.name[nlen] = 0;
p->in_call = 1;
ctl_event_t ev = {0};
ev.type = CTL_EV_CALL_OPEN;
ev.call = &p->call;
emit(p, &ev);
return 0;
}
static int finish_arg(ctl_parser_t *p);
static int parse_arg_header(ctl_parser_t *p, const char *line) {
/* Forms:
* arg <key> <nbytes> → length-prefixed body follows
* arg <key> <value...> → value is rest of line (line-mode, confirmed now)
*/
const char *s = line + 4;
while (*s == ' ' || *s == '\t')
s++;
if (!*s)
return -1;
const char *sp = strchr(s, ' ');
if (!sp)
return -1;
size_t klen = (size_t)(sp - s);
if (klen == 0 || klen >= CTL_MAX_KEY)
return -1;
while (*sp == ' ' || *sp == '\t')
sp++;
if (!*sp)
return -1;
if (p->call.nargs >= CTL_MAX_ARGS)
return -1;
char *end = NULL;
unsigned long nbytes = strtoul(sp, &end, 10);
int length_prefixed = (end != sp && (*end == '\0' || *end == ' ' || *end == '\t'));
if (length_prefixed && end && *end != '\0') {
while (*end == ' ' || *end == '\t')
end++;
if (*end != '\0')
length_prefixed = 0; /* e.g. "9foo" or value starting with digits+text */
}
memcpy(p->arg_key, s, klen);
p->arg_key[klen] = 0;
if (!length_prefixed) {
/* line-mode: entire remainder is the value */
size_t vlen = strlen(sp);
if (vlen > CTL_MAX_ARG_BYTES)
return -1;
free(p->arg_buf);
p->arg_buf = malloc(vlen + 1);
if (!p->arg_buf)
return -2;
memcpy(p->arg_buf, sp, vlen);
p->arg_buf[vlen] = 0;
p->arg_need = vlen;
p->arg_got = vlen;
p->arg_cap = vlen;
finish_arg(p);
return 0;
}
if (nbytes > CTL_MAX_ARG_BYTES)
return -1;
p->arg_need = (size_t)nbytes;
p->arg_got = 0;
free(p->arg_buf);
p->arg_buf = NULL;
p->arg_cap = 0;
if (p->arg_need > 0) {
p->arg_buf = malloc(p->arg_need + 1);
if (!p->arg_buf)
return -2;
p->arg_cap = p->arg_need;
}
p->state = ST_ARG_BODY;
bump_pending(p, p->arg_need);
return 0;
}
static int finish_arg(ctl_parser_t *p) {
int i = p->call.nargs;
snprintf(p->call.args[i].key, CTL_MAX_KEY, "%s", p->arg_key);
p->call.args[i].value = p->arg_buf;
p->call.args[i].value_len = p->arg_got;
if (p->arg_buf)
p->arg_buf[p->arg_got] = 0;
p->arg_buf = NULL; /* ownership moved */
p->call.nargs++;
p->arg_need = 0;
p->arg_got = 0;
p->arg_key[0] = 0;
p->state = ST_LINE;
bump_pending(p, 0);
ctl_event_t ev = {0};
ev.type = CTL_EV_ARG;
ev.call = &p->call;
emit(p, &ev);
return 0;
}
static void emit_text_line(ctl_parser_t *p, const char *line, size_t len) {
/* include newline as confirmed text */
char tmp[CTL_MAX_LINE + 1];
if (len + 1 >= sizeof(tmp))
len = sizeof(tmp) - 2;
memcpy(tmp, line, len);
tmp[len] = '\n';
tmp[len + 1] = 0;
ctl_event_t ev = {0};
ev.type = CTL_EV_TEXT;
ev.text = tmp;
ev.text_len = len + 1;
emit(p, &ev);
}
static int is_fence_line(const char *line) {
if (strncmp(line, "```", 3) == 0)
return 1;
if (strncmp(line, "~~~", 3) == 0)
return 1;
return 0;
}
static int handle_line(ctl_parser_t *p, const char *line) {
/* Ignore markdown fences so models can wrap CTL in code blocks. */
if (is_fence_line(line)) {
bump_pending(p, 0);
return 0;
}
/* Ignore blank lines between CTL frames. */
if (line[0] == '\0') {
bump_pending(p, 0);
return 0;
}
if (!p->in_call) {
if (starts_with(line, "call ")) {
if (parse_call_line(p, line) != 0) {
fail(p, "invalid call line");
return -1;
}
bump_pending(p, 0);
return 0;
}
/* confirmed text line */
emit_text_line(p, line, strlen(line));
bump_pending(p, 0);
return 0;
}
/* in call */
if (starts_with(line, "arg ")) {
int rc = parse_arg_header(p, line);
if (rc == -2) {
fail(p, "oom");
return -1;
}
if (rc != 0) {
fail(p, "invalid arg header");
return -1;
}
if (p->arg_need == 0)
finish_arg(p);
return 0;
}
if (strcmp(line, "end") == 0) {
ctl_event_t ev = {0};
ev.type = CTL_EV_CALL_READY;
ev.call = &p->call;
emit(p, &ev);
clear_call_args(&p->call);
p->in_call = 0;
bump_pending(p, 0);
p->state = ST_LINE;
return 0;
}
fail(p, "unexpected line inside call");
return -1;
}
ctl_status_t ctl_parser_feed(ctl_parser_t *p, const char *data, size_t len) {
if (!p)
return CTL_ERR_CONTRACT;
if (p->state == ST_ERROR)
return CTL_ERR_CONTRACT;
size_t i = 0;
while (i < len) {
if (p->state == ST_ARG_BODY) {
size_t left = p->arg_need - p->arg_got;
size_t take = len - i;
if (take > left)
take = left;
if (take > 0) {
memcpy(p->arg_buf + p->arg_got, data + i, take);
p->arg_got += take;
i += take;
bump_pending(p, p->arg_need - p->arg_got);
}
if (p->arg_got == p->arg_need) {
finish_arg(p);
/* continue with remaining bytes in LINE mode */
}
continue;
}
/* line accumulation */
p->state = ST_LINE;
unsigned char c = (unsigned char)data[i++];
if (c == '\r')
continue; /* normalize CRLF */
if (c == '\n') {
p->line[p->line_len] = 0;
if (handle_line(p, p->line) != 0)
return CTL_ERR_CONTRACT;
p->line_len = 0;
if (p->state == ST_ARG_BODY && p->arg_need == 0) {
/* already finished empty arg */
}
continue;
}
if (p->line_len + 1 >= CTL_MAX_LINE) {
fail(p, "line too long");
return CTL_ERR_LIMIT;
}
p->line[p->line_len++] = (char)c;
bump_pending(p, p->line_len);
}
return CTL_OK;
}
ctl_status_t ctl_parser_finish(ctl_parser_t *p) {
if (!p)
return CTL_ERR_CONTRACT;
if (p->state == ST_ERROR)
return CTL_ERR_CONTRACT;
/* Mid-argument body cannot be confirmed at EOF. */
if (p->state == ST_ARG_BODY) {
fail(p, "incomplete arg body at EOF");
return CTL_ERR_CONTRACT;
}
/* Trailing text without final newline is still confirmed text. */
if (!p->in_call && p->line_len > 0) {
p->line[p->line_len] = 0;
emit_text_line(p, p->line, p->line_len);
p->line_len = 0;
}
/* EOF confirms an open call whose args are complete (implicit end).
* Matches paper EOF adequacy: stream end closes the frontier. */
if (p->in_call) {
if (p->line_len > 0) {
p->line[p->line_len] = 0;
/* leftover line must be end or fence/blank */
if (strcmp(p->line, "end") == 0 || is_fence_line(p->line) ||
p->line[0] == '\0') {
p->line_len = 0;
} else {
fail(p, "unexpected trailing line in call at EOF");
return CTL_ERR_CONTRACT;
}
}
ctl_event_t ev = {0};
ev.type = CTL_EV_CALL_READY;
ev.call = &p->call;
emit(p, &ev);
clear_call_args(&p->call);
p->in_call = 0;
}
bump_pending(p, 0);
return CTL_OK;
}
+145
View File
@@ -0,0 +1,145 @@
#include "cst_agent.h"
#include <stdio.h>
#include <string.h>
/* Minimal JSON tool object: {"name":"...","arguments":{...}}
* Buffer-until-valid baseline (NOT bounded CST). */
static const char *skip_ws(const char *p, const char *end) {
while (p < end && (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t'))
p++;
return p;
}
/* Find matching closing brace/bracket starting at open pos. Returns end ptr or
* NULL if incomplete. Handles strings/escapes. */
static const char *match_obj(const char *start, const char *end) {
if (start >= end || *start != '{')
return NULL;
int depth = 0;
int in_str = 0;
for (const char *p = start; p < end; p++) {
if (in_str) {
if (*p == '\\' && p + 1 < end) {
p++;
continue;
}
if (*p == '"')
in_str = 0;
continue;
}
if (*p == '"') {
in_str = 1;
continue;
}
if (*p == '{')
depth++;
else if (*p == '}') {
depth--;
if (depth == 0)
return p + 1;
}
}
return NULL; /* incomplete */
}
static int extract_string(const char *obj, const char *end, const char *key,
char *out, size_t out_sz) {
char pat[128];
snprintf(pat, sizeof(pat), "\"%s\"", key);
const char *k = obj;
while (k && k < end) {
k = strstr(k, pat);
if (!k || k >= end)
return -1;
/* ensure key boundary */
const char *colon = k + strlen(pat);
colon = skip_ws(colon, end);
if (colon >= end || *colon != ':') {
k++;
continue;
}
colon++;
colon = skip_ws(colon, end);
if (colon >= end || *colon != '"')
return -1;
colon++;
size_t o = 0;
for (const char *p = colon; p < end && o + 1 < out_sz; p++) {
if (*p == '\\' && p + 1 < end) {
p++;
if (*p == 'n')
out[o++] = '\n';
else
out[o++] = *p;
continue;
}
if (*p == '"') {
out[o] = 0;
return 0;
}
out[o++] = *p;
}
return -1;
}
return -1;
}
int cst_json_try_parse_tool(const char *buf, size_t len, cst_json_tool_t *out) {
if (!buf || !out)
return 0;
memset(out, 0, sizeof(*out));
const char *end = buf + len;
const char *p = buf;
while (p < end) {
p = skip_ws(p, end);
if (p >= end)
break;
if (*p != '{') {
p++;
continue;
}
const char *obj_end = match_obj(p, end);
if (!obj_end)
return 0; /* incomplete JSON — starvation failure mode */
if (extract_string(p, obj_end, "name", out->name, sizeof(out->name)) !=
0) {
p++;
continue;
}
/* arguments: object as raw substring */
const char *akey = strstr(p, "\"arguments\"");
if (!akey || akey >= obj_end) {
p = obj_end;
continue;
}
const char *colon = strchr(akey + 11, ':');
if (!colon || colon >= obj_end)
return 0;
colon++;
colon = skip_ws(colon, obj_end);
if (colon >= obj_end)
return 0;
if (*colon == '{') {
const char *ae = match_obj(colon, obj_end);
if (!ae)
return 0;
size_t n = (size_t)(ae - colon);
if (n >= sizeof(out->arguments_json))
return 0;
memcpy(out->arguments_json, colon, n);
out->arguments_json[n] = 0;
} else if (*colon == '"') {
if (extract_string(akey, obj_end, "arguments", out->arguments_json,
sizeof(out->arguments_json)) != 0)
return 0;
} else {
p = obj_end;
continue;
}
out->ok = 1;
return 1;
}
return 0;
}
+786
View File
@@ -0,0 +1,786 @@
#include "cst_agent.h"
#include <ctype.h>
#include <curl/curl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define TC_SLOTS 8
#define TC_ID_MAX 128
#define TC_NAME_MAX 128
#define TC_ARGS_MAX 65536
typedef struct {
int used;
int index;
char id[TC_ID_MAX];
char name[TC_NAME_MAX];
char *args;
size_t args_len;
size_t args_cap;
} tc_slot_t;
typedef struct {
cst_llm_on_delta_fn on_delta;
cst_llm_on_native_tool_fn on_native_tool;
void *userdata;
char *buf;
size_t len;
size_t cap;
int in_data;
int in_reason; /* wrapping reasoning_content as <think> for the agent */
tc_slot_t slots[TC_SLOTS];
} sse_acc_t;
static void emit_raw(sse_acc_t *a, const char *s, size_t n) {
if (a && a->on_delta && s && n)
a->on_delta(s, n, a->userdata);
}
static const char *find_json_string_key(const char *payload, const char *key) {
/* Find "key" that is a full JSON key, not a suffix of another key
* (e.g. avoid matching content inside audio_content / reasoning_content). */
size_t klen = strlen(key);
const char *p = payload;
while ((p = strstr(p, key)) != NULL) {
if (p > payload) {
unsigned char prev = (unsigned char)p[-1];
if (prev == '_' || isalnum(prev)) {
p += klen;
continue;
}
}
return p;
}
return NULL;
}
/* Decode one JSON string value starting after the opening quote into out. */
static size_t decode_json_string(const char *colon_quote, char *out,
size_t out_sz) {
size_t o = 0;
for (const char *p = colon_quote; *p && o + 4 < out_sz; p++) {
if (*p == '\\' && p[1]) {
p++;
if (*p == 'n')
out[o++] = '\n';
else if (*p == 'r')
out[o++] = '\r';
else if (*p == 't')
out[o++] = '\t';
else if (*p == '"' || *p == '\\' || *p == '/')
out[o++] = *p;
else if (*p == 'u' && p[1] && p[2] && p[3] && p[4]) {
unsigned cp = 0;
int ok = 1;
for (int i = 1; i <= 4; i++) {
char h = p[i];
cp <<= 4;
if (h >= '0' && h <= '9')
cp |= (unsigned)(h - '0');
else if (h >= 'a' && h <= 'f')
cp |= (unsigned)(h - 'a' + 10);
else if (h >= 'A' && h <= 'F')
cp |= (unsigned)(h - 'A' + 10);
else {
ok = 0;
break;
}
}
p += 4;
if (!ok) {
out[o++] = '?';
continue;
}
if (cp < 0x80)
out[o++] = (char)cp;
else if (cp < 0x800) {
out[o++] = (char)(0xC0 | (cp >> 6));
out[o++] = (char)(0x80 | (cp & 0x3F));
} else {
out[o++] = (char)(0xE0 | (cp >> 12));
out[o++] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[o++] = (char)(0x80 | (cp & 0x3F));
}
} else {
out[o++] = *p;
}
continue;
}
if (*p == '"')
break;
out[o++] = *p;
}
return o;
}
static int extract_json_string_value(const char *payload, const char *key,
char *out, size_t out_sz, size_t *out_len) {
const char *keyp = find_json_string_key(payload, key);
if (!keyp)
return 0;
const char *colon = strchr(keyp + strlen(key), ':');
if (!colon)
return 0;
colon++;
while (*colon == ' ')
colon++;
if (*colon == 'n' && strncmp(colon, "null", 4) == 0)
return 0;
if (*colon != '"')
return 0;
if (colon[1] == '"')
return 0; /* empty */
size_t n = decode_json_string(colon + 1, out, out_sz);
if (n == 0)
return 0;
*out_len = n;
return 1;
}
static int json_brace_balanced(const char *s) {
if (!s || *s != '{')
return 0;
int depth = 0;
int in_str = 0;
for (const char *p = s; *p; p++) {
if (in_str) {
if (*p == '\\' && p[1]) {
p++;
continue;
}
if (*p == '"')
in_str = 0;
continue;
}
if (*p == '"') {
in_str = 1;
continue;
}
if (*p == '{')
depth++;
else if (*p == '}') {
depth--;
if (depth == 0)
return p[1] == 0;
if (depth < 0)
return 0;
}
}
return 0;
}
static tc_slot_t *slot_for_index(sse_acc_t *a, int index) {
for (int i = 0; i < TC_SLOTS; i++) {
if (a->slots[i].used && a->slots[i].index == index)
return &a->slots[i];
}
for (int i = 0; i < TC_SLOTS; i++) {
if (!a->slots[i].used) {
a->slots[i].used = 1;
a->slots[i].index = index;
a->slots[i].id[0] = 0;
a->slots[i].name[0] = 0;
a->slots[i].args = NULL;
a->slots[i].args_len = 0;
a->slots[i].args_cap = 0;
return &a->slots[i];
}
}
return NULL;
}
static int slot_append_args(tc_slot_t *s, const char *frag, size_t n) {
if (!s || !frag || !n)
return 0;
if (s->args_len + n + 1 > s->args_cap) {
size_t nc = s->args_cap ? s->args_cap * 2 : 1024;
while (nc < s->args_len + n + 1)
nc *= 2;
if (nc > TC_ARGS_MAX)
nc = TC_ARGS_MAX;
if (s->args_len + n + 1 > nc)
return -1;
char *nb = realloc(s->args, nc);
if (!nb)
return -1;
s->args = nb;
s->args_cap = nc;
}
memcpy(s->args + s->args_len, frag, n);
s->args_len += n;
s->args[s->args_len] = 0;
return 0;
}
/* Parse one tool_calls[] element object for index/id/name/arguments fragments. */
static void parse_one_tool_call_obj(sse_acc_t *a, const char *obj) {
if (!obj || *obj != '{')
return;
int index = 0;
const char *ip = strstr(obj, "\"index\"");
if (ip) {
const char *colon = strchr(ip, ':');
if (colon) {
colon++;
while (*colon == ' ')
colon++;
index = (int)strtol(colon, NULL, 10);
}
}
tc_slot_t *slot = slot_for_index(a, index);
if (!slot)
return;
char tmp[4096];
size_t tlen = 0;
if (extract_json_string_value(obj, "\"id\"", tmp, sizeof(tmp), &tlen)) {
if (tlen >= TC_ID_MAX)
tlen = TC_ID_MAX - 1;
memcpy(slot->id, tmp, tlen);
slot->id[tlen] = 0;
}
/* function.name / function.arguments may be nested under "function":{...} */
const char *fn = strstr(obj, "\"function\"");
const char *scope = fn ? fn : obj;
tlen = 0;
if (extract_json_string_value(scope, "\"name\"", tmp, sizeof(tmp), &tlen)) {
if (tlen >= TC_NAME_MAX)
tlen = TC_NAME_MAX - 1;
memcpy(slot->name, tmp, tlen);
slot->name[tlen] = 0;
}
tlen = 0;
if (extract_json_string_value(scope, "\"arguments\"", tmp, sizeof(tmp),
&tlen)) {
slot_append_args(slot, tmp, tlen);
}
}
static void parse_tool_calls(sse_acc_t *a, const char *payload) {
const char *key = find_json_string_key(payload, "\"tool_calls\"");
if (!key)
return;
const char *p = strchr(key, '[');
if (!p)
return;
p++;
while (*p) {
while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t' || *p == ',')
p++;
if (*p == ']')
break;
if (*p != '{')
break;
/* find matching close brace for this object */
int depth = 0;
int in_str = 0;
const char *start = p;
const char *end = NULL;
for (; *p; p++) {
if (in_str) {
if (*p == '\\' && p[1]) {
p++;
continue;
}
if (*p == '"')
in_str = 0;
continue;
}
if (*p == '"') {
in_str = 1;
continue;
}
if (*p == '{')
depth++;
else if (*p == '}') {
depth--;
if (depth == 0) {
end = p;
p++;
break;
}
}
}
if (!end)
break;
size_t n = (size_t)(end - start + 1);
if (n >= 8192)
n = 8191;
char obj[8192];
memcpy(obj, start, n);
obj[n] = 0;
parse_one_tool_call_obj(a, obj);
}
}
static void flush_native_tools(sse_acc_t *a) {
if (!a->on_native_tool)
return;
for (int i = 0; i < TC_SLOTS; i++) {
tc_slot_t *s = &a->slots[i];
if (!s->used || !s->name[0] || !s->args || !s->args_len)
continue;
if (!json_brace_balanced(s->args))
continue;
const char *id = s->id[0] ? s->id : "";
a->on_native_tool(id, s->name, s->args, a->userdata);
/* Mark consumed so end-of-stream flush is idempotent after [DONE]. */
free(s->args);
s->args = NULL;
s->args_len = 0;
s->used = 0;
}
}
static void free_slots(sse_acc_t *a) {
for (int i = 0; i < TC_SLOTS; i++) {
free(a->slots[i].args);
a->slots[i].args = NULL;
a->slots[i].used = 0;
}
}
static void emit_data_payload(sse_acc_t *a, const char *payload) {
/* OpenAI SSE: data: {...} or data: [DONE] */
if (strncmp(payload, "[DONE]", 6) == 0) {
if (a->in_reason) {
emit_raw(a, "</think>", 8);
a->in_reason = 0;
}
flush_native_tools(a);
return;
}
char out[8192];
size_t olen = 0;
/* MiniMax reasoning_split: route reasoning_content through <think> so the
* agent UI can show it, without polluting the final answer. */
if (extract_json_string_value(payload, "\"reasoning_content\"", out,
sizeof(out), &olen)) {
if (!a->in_reason) {
emit_raw(a, "<think>", 7);
a->in_reason = 1;
}
emit_raw(a, out, olen);
}
olen = 0;
if (extract_json_string_value(payload, "\"content\"", out, sizeof(out),
&olen)) {
if (a->in_reason) {
emit_raw(a, "</think>", 8);
a->in_reason = 0;
}
emit_raw(a, out, olen);
}
parse_tool_calls(a, payload);
}
static void process_sse_chunk(sse_acc_t *a, const char *data, size_t n) {
for (size_t i = 0; i < n; i++) {
if (a->len + 1 >= a->cap) {
size_t nc = a->cap ? a->cap * 2 : 4096;
char *nb = realloc(a->buf, nc);
if (!nb)
return;
a->buf = nb;
a->cap = nc;
}
a->buf[a->len++] = data[i];
if (data[i] == '\n') {
a->buf[a->len] = 0;
/* trim CR */
if (a->len >= 2 && a->buf[a->len - 2] == '\r')
a->buf[a->len - 2] = 0;
else
a->buf[a->len - 1] = 0;
if (strncmp(a->buf, "data:", 5) == 0) {
const char *p = a->buf + 5;
while (*p == ' ')
p++;
emit_data_payload(a, p);
}
a->len = 0;
}
}
}
static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) {
sse_acc_t *a = userdata;
size_t n = size * nmemb;
process_sse_chunk(a, ptr, n);
return n;
}
static int xferinfo_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow,
curl_off_t ultotal, curl_off_t ulnow) {
(void)dltotal;
(void)dlnow;
(void)ultotal;
(void)ulnow;
volatile int *flag = (volatile int *)clientp;
if (flag && *flag != 0)
return 1; /* abort */
return 0;
}
static int json_escape_append(char **buf, size_t *len, size_t *cap,
const char *s) {
if (!buf || !len || !cap)
return -1;
if (!*buf || *cap == 0) {
*cap = 4096;
*buf = malloc(*cap);
if (!*buf)
return -1;
*len = 0;
(*buf)[0] = 0;
}
if (!s)
s = "";
for (; *s; s++) {
char tmp[8];
const char *piece = tmp;
size_t pn = 1;
unsigned char c = (unsigned char)*s;
if (c == '"' || c == '\\') {
tmp[0] = '\\';
tmp[1] = (char)c;
pn = 2;
} else if (c == '\n') {
piece = "\\n";
pn = 2;
} else if (c == '\r') {
piece = "\\r";
pn = 2;
} else if (c == '\t') {
piece = "\\t";
pn = 2;
} else if (c < 0x20) {
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
pn = 6;
} else {
tmp[0] = (char)c;
pn = 1;
}
if (*len + pn + 1 > *cap) {
size_t nc = *cap ? *cap * 2 : 4096;
while (nc < *len + pn + 1)
nc *= 2;
char *nb = realloc(*buf, nc);
if (!nb)
return -1;
*buf = nb;
*cap = nc;
}
memcpy(*buf + *len, piece, pn);
*len += pn;
}
(*buf)[*len] = 0;
return 0;
}
static int body_ensure(char **body, size_t *blen, size_t *bcap, size_t need) {
if (*blen + need + 1 <= *bcap)
return 0;
size_t nc = *bcap ? *bcap * 2 : 4096;
while (nc < *blen + need + 1)
nc *= 2;
char *nb = realloc(*body, nc);
if (!nb)
return -1;
*body = nb;
*bcap = nc;
return 0;
}
static int body_append_raw(char **body, size_t *blen, size_t *bcap,
const char *s) {
size_t n = strlen(s);
if (body_ensure(body, blen, bcap, n) != 0)
return -1;
memcpy(*body + *blen, s, n);
*blen += n;
(*body)[*blen] = 0;
return 0;
}
static const char *role_api(cst_role_t r) {
switch (r) {
case CST_ROLE_SYSTEM:
return "system";
case CST_ROLE_USER:
return "user";
case CST_ROLE_ASSISTANT:
return "assistant";
case CST_ROLE_TOOL:
return "tool";
}
return "user";
}
static const char *TOOLS_JSON =
",\"tools\":["
"{\"type\":\"function\",\"function\":{\"name\":\"read_file\","
"\"description\":\"Read a text file under the workspace\","
"\"parameters\":{\"type\":\"object\",\"properties\":{"
"\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}},"
"{\"type\":\"function\",\"function\":{\"name\":\"run_shell\","
"\"description\":\"Run a shell command in the workspace root\","
"\"parameters\":{\"type\":\"object\",\"properties\":{"
"\"cmd\":{\"type\":\"string\"}},\"required\":[\"cmd\"]}}},"
"{\"type\":\"function\",\"function\":{\"name\":\"write_file\","
"\"description\":\"Write full file contents under the workspace\","
"\"parameters\":{\"type\":\"object\",\"properties\":{"
"\"path\":{\"type\":\"string\"},\"content\":{\"type\":\"string\"}},"
"\"required\":[\"path\",\"content\"]}}},"
"{\"type\":\"function\",\"function\":{\"name\":\"apply_patch\","
"\"description\":\"Apply a unified diff or Update File patch\","
"\"parameters\":{\"type\":\"object\",\"properties\":{"
"\"patch\":{\"type\":\"string\"}},\"required\":[\"patch\"]}}}"
"]";
int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, size_t err_sz) {
if (err && err_sz)
err[0] = 0;
if (!req || !req->api_base || !req->model) {
if (err)
snprintf(err, err_sz, "bad request");
return -1;
}
char *body = NULL;
size_t blen = 0;
size_t bcap = 8192;
body = malloc(bcap);
if (!body)
return -1;
/* MiniMax: keep think+answer in content (no reasoning_split). Split mode
* often leaves content empty while the useful text is only in
* reasoning_content, which made the agent exit with blank turns. */
blen = (size_t)snprintf(body, bcap,
"{\"model\":\"%s\",\"stream\":true,\"messages\":[",
req->model);
for (int i = 0; i < req->nmsgs; i++) {
const cst_message_t *m = &req->msgs[i];
char prefix[256];
int first = i ? 0 : 1;
if (m->role == CST_ROLE_ASSISTANT && m->tool_calls_json &&
m->tool_calls_json[0]) {
/* OpenCode/OpenAI: tool-only assistant uses content:null */
int empty =
!m->content || !m->content[0] ||
(m->content[0] == '\0');
if (empty) {
snprintf(prefix, sizeof(prefix),
"%s{\"role\":\"assistant\",\"content\":null,\"tool_calls\":",
first ? "" : ",");
if (body_append_raw(&body, &blen, &bcap, prefix) != 0 ||
body_append_raw(&body, &blen, &bcap, m->tool_calls_json) != 0 ||
body_append_raw(&body, &blen, &bcap, "}") != 0) {
free(body);
return -1;
}
} else {
snprintf(prefix, sizeof(prefix),
"%s{\"role\":\"assistant\",\"content\":\"", first ? "" : ",");
size_t pl = strlen(prefix);
if (blen + pl + 8 > bcap) {
bcap = (blen + pl) * 2 + 1024;
body = realloc(body, bcap);
if (!body)
return -1;
}
memcpy(body + blen, prefix, pl);
blen += pl;
body[blen] = 0;
if (json_escape_append(&body, &blen, &bcap, m->content) != 0) {
free(body);
return -1;
}
if (body_append_raw(&body, &blen, &bcap, "\",\"tool_calls\":") != 0 ||
body_append_raw(&body, &blen, &bcap, m->tool_calls_json) != 0 ||
body_append_raw(&body, &blen, &bcap, "}") != 0) {
free(body);
return -1;
}
}
continue;
}
if (m->role == CST_ROLE_TOOL && m->tool_call_id[0]) {
/* Include name when known (OpenAI-compatible gateways accept it). */
if (m->tool_name[0]) {
snprintf(prefix, sizeof(prefix),
"%s{\"role\":\"%s\",\"tool_call_id\":\"%s\",\"name\":\"%s\","
"\"content\":\"",
first ? "" : ",", role_api(m->role), m->tool_call_id,
m->tool_name);
} else {
snprintf(prefix, sizeof(prefix),
"%s{\"role\":\"%s\",\"tool_call_id\":\"%s\",\"content\":\"",
first ? "" : ",", role_api(m->role), m->tool_call_id);
}
} else {
snprintf(prefix, sizeof(prefix), "%s{\"role\":\"%s\",\"content\":\"",
first ? "" : ",", role_api(m->role));
}
size_t pl = strlen(prefix);
if (blen + pl + 8 > bcap) {
bcap = (blen + pl) * 2 + 1024;
body = realloc(body, bcap);
if (!body)
return -1;
}
memcpy(body + blen, prefix, pl);
blen += pl;
body[blen] = 0;
/* Keep full tool output in session/TUI; shrink only the wire payload so
* large write diffs don't blow MiniMax/JSON limits. */
{
const char *raw = m->content ? m->content : "";
char *scratch = NULL;
const char *send = raw;
enum { API_TOOL_CAP = 3500 };
if (m->role == CST_ROLE_TOOL && strlen(raw) > API_TOOL_CAP) {
scratch = malloc(API_TOOL_CAP + 80);
if (scratch) {
snprintf(scratch, API_TOOL_CAP + 80,
"%.*s\n…(truncated for model; full diff in UI/session)",
API_TOOL_CAP, raw);
send = scratch;
}
}
if (json_escape_append(&body, &blen, &bcap, send) != 0) {
free(scratch);
free(body);
return -1;
}
free(scratch);
}
if (blen + 3 > bcap) {
bcap *= 2;
body = realloc(body, bcap);
if (!body)
return -1;
}
body[blen++] = '"';
body[blen++] = '}';
body[blen] = 0;
}
if (body_append_raw(&body, &blen, &bcap, "]") != 0) {
free(body);
return -1;
}
if (req->enable_tools) {
if (body_append_raw(&body, &blen, &bcap, TOOLS_JSON) != 0) {
free(body);
return -1;
}
if (req->tool_choice_none) {
if (body_append_raw(&body, &blen, &bcap, ",\"tool_choice\":\"none\"") !=
0) {
free(body);
return -1;
}
} else {
if (body_append_raw(&body, &blen, &bcap, ",\"tool_choice\":\"auto\"") !=
0) {
free(body);
return -1;
}
}
}
if (body_append_raw(&body, &blen, &bcap, "}") != 0) {
free(body);
return -1;
}
char url[1024];
snprintf(url, sizeof(url), "%s/chat/completions", req->api_base);
CURL *curl = curl_easy_init();
if (!curl) {
free(body);
if (err)
snprintf(err, err_sz, "curl init failed");
return -1;
}
sse_acc_t acc = {0};
acc.on_delta = req->on_delta;
acc.on_native_tool = req->on_native_tool;
acc.userdata = req->userdata;
struct curl_slist *hdrs = NULL;
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
char auth[600];
if (req->api_key && req->api_key[0]) {
snprintf(auth, sizeof(auth), "Authorization: Bearer %s", req->api_key);
hdrs = curl_slist_append(hdrs, auth);
}
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &acc);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 300L);
if (req->cancel_flag) {
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferinfo_cb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, (void *)req->cancel_flag);
}
CURLcode rc = curl_easy_perform(curl);
long http = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http);
if (acc.in_reason) {
emit_raw(&acc, "</think>", 8);
acc.in_reason = 0;
}
/* Stream ended without [DONE] — still flush completed native tools. */
if (rc == CURLE_OK)
flush_native_tools(&acc);
curl_slist_free_all(hdrs);
curl_easy_cleanup(curl);
if (rc != CURLE_OK) {
if (err) {
if (rc == CURLE_ABORTED_BY_CALLBACK)
snprintf(err, err_sz, "cancelled");
else
snprintf(err, err_sz, "curl: %s", curl_easy_strerror(rc));
}
free_slots(&acc);
free(acc.buf);
free(body);
return -1;
}
if (http >= 400) {
if (err) {
/* show a snippet of body if present */
if (acc.buf && acc.len) {
acc.buf[acc.len < 200 ? acc.len : 200] = 0;
snprintf(err, err_sz, "http %ld: %.180s", http, acc.buf);
} else {
snprintf(err, err_sz, "http %ld", http);
}
}
free_slots(&acc);
free(acc.buf);
free(body);
return -1;
}
free_slots(&acc);
free(acc.buf);
free(body);
return 0;
}
+283
View File
@@ -0,0 +1,283 @@
#include "cst_agent.h"
#include "os_compat.h"
#include <curl/curl.h>
#include <getopt.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <direct.h>
#else
#include <unistd.h>
#endif
static void usage(const char *argv0) {
fprintf(stderr,
"Usage: %s [--workspace DIR] [--session FILE] [--api-base URL]\n"
" [--api-key KEY] [--model NAME] [--headless]\n"
" [--prompt TEXT] [--demo-ctl] [--ask-key]\n"
"\n"
"Defaults: api-base=https://api.minimax.cn/v1 model=MiniMax-M2.7\n"
"Session: <workspace>/.agent/sessions/<id>.jsonl (CURRENT tracked)\n"
"API key: CST_API_KEY / OPENAI_API_KEY / MINIMAX_API_KEY\n"
"Base: CST_API_BASE / OPENAI_BASE_URL (OpenAI-compatible …/v1)\n"
"Model: CST_MODEL / OPENAI_MODEL\n"
"Also: CST_MODELS, CST_AUTO_APPROVE=1, CST_SHELL, CST_EXE_DIR\n"
"TUI: /new /session /model /compact /details /help\n"
" Enter send · Ctrl+N newline · ↑↓ history · Esc cancel\n"
" busy Enter queues · tool perm y/n/a=always · wheel scroll\n",
argv0);
}
static int g_ready;
static ctl_tool_call_t g_call;
static void demo_on_ev(const ctl_event_t *ev, void *ud) {
(void)ud;
if (ev->type == CTL_EV_TEXT)
fwrite(ev->text, 1, ev->text_len, stdout);
if (ev->type == CTL_EV_CALL_READY) {
snprintf(g_call.id, sizeof(g_call.id), "%s", ev->call->id);
snprintf(g_call.name, sizeof(g_call.name), "%s", ev->call->name);
for (int i = 0; i < g_call.nargs; i++) {
free(g_call.args[i].value);
g_call.args[i].value = NULL;
}
g_call.nargs = 0;
for (int i = 0; i < ev->call->nargs; i++) {
snprintf(g_call.args[i].key, sizeof(g_call.args[i].key), "%s",
ev->call->args[i].key);
g_call.args[i].value_len = ev->call->args[i].value_len;
g_call.args[i].value = malloc(ev->call->args[i].value_len + 1);
memcpy(g_call.args[i].value, ev->call->args[i].value,
ev->call->args[i].value_len);
g_call.args[i].value[ev->call->args[i].value_len] = 0;
g_call.nargs++;
}
g_ready = 1;
}
}
static int demo_ctl_impl(const char *workspace) {
printf("=== demo CTL (byte-at-a-time feed) ===\n");
g_ready = 0;
memset(&g_call, 0, sizeof(g_call));
ctl_parser_t *p = ctl_parser_create(demo_on_ev, NULL);
const char *stream =
"Reading README.\n"
"call t1 read_file\n"
"arg path 9\n"
"README.md"
"end\n";
for (size_t i = 0; stream[i]; i++)
ctl_parser_feed(p, &stream[i], 1);
if (ctl_parser_finish(p) != CTL_OK || !g_ready) {
fprintf(stderr, "demo: CTL failed\n");
ctl_parser_destroy(p);
return 1;
}
printf("\npending_peak=%zu\n", ctl_parser_pending_peak(p));
ctl_parser_destroy(p);
cst_tool_result_t r = {0};
cst_tool_dispatch(workspace, &g_call, &r);
printf("[tool result exit=%d]\n%.*s\n", r.exit_code, (int)r.out_len,
r.out ? r.out : "");
cst_tool_result_free(&r);
for (int i = 0; i < g_call.nargs; i++)
free(g_call.args[i].value);
return 0;
}
int main(int argc, char **argv) {
os_set_prog_path(argv[0]);
curl_global_init(CURL_GLOBAL_DEFAULT);
cst_agent_config_t cfg = {0};
char cwd[4096];
#ifdef _WIN32
if (!_getcwd(cwd, (int)sizeof(cwd)))
#else
if (!getcwd(cwd, sizeof(cwd)))
#endif
snprintf(cwd, sizeof(cwd), ".");
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", cwd);
cfg.session_path[0] = 0; /* filled after workspace opts via .agent/store */
/* OpenAI-compatible base URL aliases */
const char *env_base = getenv("CST_API_BASE");
if (!env_base || !env_base[0])
env_base = getenv("OPENAI_BASE_URL");
if (!env_base || !env_base[0])
env_base = getenv("OPENAI_API_BASE");
const char *env_model = getenv("CST_MODEL");
if (!env_model || !env_model[0])
env_model = getenv("OPENAI_MODEL");
snprintf(cfg.api_base, sizeof(cfg.api_base), "%s",
env_base && env_base[0] ? env_base : "https://api.minimax.cn/v1");
cfg.api_key[0] = 0;
snprintf(cfg.model, sizeof(cfg.model), "%s",
env_model && env_model[0] ? env_model : "MiniMax-M2.7");
cfg.headless = 0;
cfg.confirm_shell = 1;
cfg.confirm_write = 1;
cfg.auto_approve = 0;
if (getenv("CST_AUTO_APPROVE"))
cfg.auto_approve = 1;
int do_demo = 0;
int force_ask_key = 0;
int session_explicit = 0;
const char *prompt = NULL;
int key_from_cli = 0;
static struct option opts[] = {
{"workspace", required_argument, 0, 'w'},
{"session", required_argument, 0, 's'},
{"api-base", required_argument, 0, 'b'},
{"api-key", required_argument, 0, 'k'},
{"model", required_argument, 0, 'm'},
{"headless", no_argument, 0, 'H'},
{"prompt", required_argument, 0, 'p'},
{"demo-ctl", no_argument, 0, 'd'},
{"ask-key", no_argument, 0, 'a'},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0}};
int c;
while ((c = getopt_long(argc, argv, "w:s:b:k:m:Hp:dah", opts, NULL)) != -1) {
switch (c) {
case 'w':
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg);
break;
case 's':
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s", optarg);
session_explicit = 1;
break;
case 'b':
snprintf(cfg.api_base, sizeof(cfg.api_base), "%s", optarg);
break;
case 'k':
snprintf(cfg.api_key, sizeof(cfg.api_key), "%s", optarg);
key_from_cli = 1;
break;
case 'm':
snprintf(cfg.model, sizeof(cfg.model), "%s", optarg);
break;
case 'H':
cfg.headless = 1;
break;
case 'p':
prompt = optarg;
break;
case 'd':
do_demo = 1;
break;
case 'a':
force_ask_key = 1;
break;
case 'h':
usage(argv[0]);
curl_global_cleanup();
return 0;
default:
usage(argv[0]);
curl_global_cleanup();
return 1;
}
}
/* trim trailing slash on api_base */
size_t alen = strlen(cfg.api_base);
while (alen > 0 && cfg.api_base[alen - 1] == '/')
cfg.api_base[--alen] = 0;
/* Headless defaults to auto-approve shell/write (no interactive confirm). */
if (cfg.headless)
cfg.auto_approve = 1;
/* Default session under <workspace>/.agent/sessions/ (OpenCode-like). */
if (!session_explicit) {
char id[128];
if (cst_agent_store_resolve_default(cfg.workspace_root, cfg.session_path,
sizeof(cfg.session_path), id,
sizeof(id)) != 0) {
fprintf(stderr, "failed to init .agent/sessions under %s\n",
cfg.workspace_root);
curl_global_cleanup();
return 1;
}
fprintf(stderr, "session %s\n", id);
}
if (do_demo) {
int rc = demo_ctl_impl(cfg.workspace_root);
curl_global_cleanup();
return rc;
}
if (force_ask_key || !key_from_cli) {
if (force_ask_key) {
if (cst_creds_prompt_api_key(cfg.api_key, sizeof(cfg.api_key)) != 0) {
fprintf(stderr, "API key prompt cancelled\n");
curl_global_cleanup();
return 1;
}
if (cst_creds_save_api_key(cfg.api_key) != 0)
fprintf(stderr, "warning: failed to save credentials\n");
else
fprintf(stderr, "saved API key to ~/.config/cst-agent/credentials\n");
/* --ask-key alone: save and exit unless running a turn/TUI */
if (!cfg.headless && !prompt) {
fprintf(stderr, "using %s model=%s\n", cfg.api_base, cfg.model);
curl_global_cleanup();
return 0;
}
} else if (!cfg.api_key[0]) {
if (cst_creds_ensure_api_key(cfg.api_key, sizeof(cfg.api_key)) != 0) {
fprintf(stderr, "no API key (set CST_API_KEY or use --ask-key)\n");
curl_global_cleanup();
return 1;
}
}
} else if (key_from_cli) {
/* persist CLI-provided key so next launch can reuse */
if (cst_creds_save_api_key(cfg.api_key) != 0)
fprintf(stderr, "warning: failed to save credentials\n");
}
fprintf(stderr, "using %s model=%s\n", cfg.api_base, cfg.model);
cst_agent_t *agent = cst_agent_create(&cfg);
if (!agent) {
fprintf(stderr, "failed to create agent (session path?)\n");
curl_global_cleanup();
return 1;
}
int rc = 0;
if (cfg.headless) {
if (!prompt) {
fprintf(stderr, "--headless requires --prompt\n");
rc = 1;
} else {
char err[512];
if (cst_agent_run_turn(agent, prompt, err, sizeof(err)) != 0) {
fprintf(stderr, "turn failed: %s\n", err);
rc = 1;
} else {
cst_session_t *s = cst_agent_session(agent);
for (int i = 0; i < cst_session_count(s); i++) {
const cst_message_t *m = cst_session_get(s, i);
printf("[%d role=%d] %s\n", i, (int)m->role,
m->content ? m->content : "");
}
}
}
} else {
rc = cst_tui_run(agent);
}
cst_agent_destroy(agent);
curl_global_cleanup();
return rc;
}
+410
View File
@@ -0,0 +1,410 @@
#include "mm_tool.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MM_OPEN "<minimax:tool_call>"
#define MM_CLOSE "</minimax:tool_call>"
#define MM_OPEN_LEN 19
#define MM_CLOSE_LEN 20
struct mm_scan {
mm_on_event_fn on_event;
void *userdata;
char *buf;
size_t len;
size_t cap;
size_t pending_peak;
int in_tool; /* 1 if last open is unmatched */
char idbuf[CTL_MAX_ID];
unsigned id_seq;
};
static void emit(mm_scan_t *s, ctl_event_t *ev) {
if (s->on_event)
s->on_event(ev, s->userdata);
}
static void emit_text(mm_scan_t *s, const char *t, size_t n) {
if (!t || !n)
return;
ctl_event_t ev = {0};
ev.type = CTL_EV_TEXT;
ev.text = t;
ev.text_len = n;
emit(s, &ev);
}
static int ensure(mm_scan_t *s, size_t need) {
if (s->len + need + 1 <= s->cap)
return 0;
size_t nc = s->cap ? s->cap * 2 : 4096;
while (nc < s->len + need + 1)
nc *= 2;
char *nb = realloc(s->buf, nc);
if (!nb)
return -1;
s->buf = nb;
s->cap = nc;
return 0;
}
static void bump_pending(mm_scan_t *s) {
if (s->len > s->pending_peak)
s->pending_peak = s->len;
}
mm_scan_t *mm_scan_create(mm_on_event_fn on_event, void *userdata) {
mm_scan_t *s = calloc(1, sizeof(*s));
if (!s)
return NULL;
s->on_event = on_event;
s->userdata = userdata;
s->id_seq = 1;
return s;
}
void mm_scan_destroy(mm_scan_t *s) {
if (!s)
return;
free(s->buf);
free(s);
}
void mm_scan_reset(mm_scan_t *s) {
if (!s)
return;
s->len = 0;
s->in_tool = 0;
if (s->buf)
s->buf[0] = 0;
}
size_t mm_scan_pending(const mm_scan_t *s) {
return s ? (s->in_tool ? s->len : 0) : 0;
}
static void clear_call(ctl_tool_call_t *c) {
for (int i = 0; i < c->nargs; i++) {
free(c->args[i].value);
c->args[i].value = NULL;
}
memset(c, 0, sizeof(*c));
}
static int add_arg(ctl_tool_call_t *c, const char *key, const char *val,
size_t vlen) {
if (c->nargs >= CTL_MAX_ARGS)
return -1;
if (strlen(key) >= CTL_MAX_KEY)
return -1;
snprintf(c->args[c->nargs].key, CTL_MAX_KEY, "%s", key);
c->args[c->nargs].value = malloc(vlen + 1);
if (!c->args[c->nargs].value)
return -1;
memcpy(c->args[c->nargs].value, val, vlen);
c->args[c->nargs].value[vlen] = 0;
c->args[c->nargs].value_len = vlen;
c->nargs++;
return 0;
}
/* Extract name="..." or name='...' from a tag start. */
static int extract_name_attr(const char *tag, char *out, size_t out_sz) {
const char *p = strstr(tag, "name=");
if (!p)
return -1;
p += 5;
char quote = 0;
if (*p == '"' || *p == '\'') {
quote = *p++;
}
size_t i = 0;
while (*p && i + 1 < out_sz) {
if (quote) {
if (*p == quote)
break;
} else {
if (*p == ' ' || *p == '>' || *p == '/')
break;
}
out[i++] = *p++;
}
out[i] = 0;
return i > 0 ? 0 : -1;
}
static void emit_call(mm_scan_t *s, ctl_tool_call_t *call) {
/* Only emit allowlisted tools — prevents "cmd" etc. from becoming tools. */
if (!call->name[0])
return;
if (strcmp(call->name, "read_file") != 0 &&
strcmp(call->name, "run_shell") != 0 &&
strcmp(call->name, "write_file") != 0 &&
strcmp(call->name, "apply_patch") != 0)
return;
ctl_event_t ev = {0};
ev.type = CTL_EV_CALL_OPEN;
ev.call = call;
emit(s, &ev);
ev.type = CTL_EV_CALL_READY;
ev.call = call;
emit(s, &ev);
}
/*
* Parse one complete tool_call inner body (between open/close tags).
* Emits one CALL_READY per invoke.
*/
static void parse_tool_block(mm_scan_t *s, const char *inner, size_t n) {
char *block = malloc(n + 1);
if (!block)
return;
memcpy(block, inner, n);
block[n] = 0;
/* Normalize: support both <invoke name="x"> and bare "invoke x" lines. */
char *p = block;
while (*p) {
/* Skip whitespace */
while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
p++;
if (!*p)
break;
ctl_tool_call_t call;
memset(&call, 0, sizeof(call));
snprintf(call.id, sizeof(call.id), "mm%u", s->id_seq++);
if (strncmp(p, "<invoke", 7) == 0) {
char *gt = strchr(p, '>');
if (!gt) {
break;
}
char tag[256];
size_t tlen = (size_t)(gt - p) + 1;
if (tlen >= sizeof(tag))
tlen = sizeof(tag) - 1;
memcpy(tag, p, tlen);
tag[tlen] = 0;
if (extract_name_attr(tag, call.name, sizeof(call.name)) != 0) {
p = gt + 1;
continue;
}
p = gt + 1;
char *end_inv = strstr(p, "</invoke>");
if (!end_inv) {
/* unclosed invoke — treat rest as parameters until end */
end_inv = block + n;
}
/* parameters / args inside */
char *q = p;
while (q < end_inv) {
char *arg = strstr(q, "<parameter");
char *arg2 = strstr(q, "<arg");
char *use = NULL;
int is_param = 0;
if (arg && (!arg2 || arg <= arg2) && arg < end_inv) {
use = arg;
is_param = 1;
} else if (arg2 && arg2 < end_inv) {
use = arg2;
is_param = 0;
} else {
break;
}
char *agt = strchr(use, '>');
if (!agt || agt >= end_inv)
break;
char atag[256];
size_t al = (size_t)(agt - use) + 1;
if (al >= sizeof(atag))
al = sizeof(atag) - 1;
memcpy(atag, use, al);
atag[al] = 0;
char key[CTL_MAX_KEY];
if (extract_name_attr(atag, key, sizeof(key)) != 0) {
q = agt + 1;
continue;
}
const char *close = is_param ? "</parameter>" : "</arg>";
char *aend = strstr(agt + 1, close);
if (!aend || aend > end_inv) {
q = agt + 1;
continue;
}
size_t vlen = (size_t)(aend - (agt + 1));
add_arg(&call, key, agt + 1, vlen);
q = aend + strlen(close);
}
emit_call(s, &call);
clear_call(&call);
if (end_inv < block + n && strncmp(end_inv, "</invoke>", 9) == 0)
p = end_inv + 9;
else
p = end_inv;
continue;
}
/* Bare: invoke NAME\n */
if (strncmp(p, "invoke ", 7) == 0) {
p += 7;
while (*p == ' ' || *p == '\t')
p++;
size_t ni = 0;
while (p[ni] && p[ni] != '\n' && p[ni] != '\r' && p[ni] != ' ' &&
p[ni] != '<' && ni + 1 < sizeof(call.name)) {
call.name[ni] = p[ni];
ni++;
}
call.name[ni] = 0;
p += ni;
while (*p == '\n' || *p == '\r' || *p == ' ' || *p == '\t')
p++;
/* Collect <arg>...</arg> until next invoke or end */
while (*p) {
while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
p++;
if (strncmp(p, "invoke ", 7) == 0 || strncmp(p, "<invoke", 7) == 0)
break;
if (strncmp(p, "<arg", 4) != 0 && strncmp(p, "<parameter", 10) != 0)
break;
int is_param = (strncmp(p, "<parameter", 10) == 0);
char *agt = strchr(p, '>');
if (!agt)
break;
char atag[256];
size_t al = (size_t)(agt - p) + 1;
if (al >= sizeof(atag))
al = sizeof(atag) - 1;
memcpy(atag, p, al);
atag[al] = 0;
char key[CTL_MAX_KEY];
if (extract_name_attr(atag, key, sizeof(key)) != 0) {
p = agt + 1;
continue;
}
const char *close = is_param ? "</parameter>" : "</arg>";
char *aend = strstr(agt + 1, close);
if (!aend)
break;
size_t vlen = (size_t)(aend - (agt + 1));
add_arg(&call, key, agt + 1, vlen);
p = aend + strlen(close);
}
if (call.name[0]) {
emit_call(s, &call);
}
clear_call(&call);
continue;
}
/* Unknown content — skip one char */
p++;
}
free(block);
}
/* Drop stray close tags that MiniMax sometimes emits outside a tool block. */
static void strip_stray_closes(mm_scan_t *s) {
for (;;) {
char *c = strstr(s->buf, MM_CLOSE);
if (!c)
return;
/* Only strip if it appears before any open (orphan). */
char *o = strstr(s->buf, MM_OPEN);
if (o && o < c)
return;
size_t at = (size_t)(c - s->buf);
size_t rest = s->len - at - MM_CLOSE_LEN;
if (at > 0)
emit_text(s, s->buf, at);
memmove(s->buf, c + MM_CLOSE_LEN, rest);
s->len = rest;
s->buf[s->len] = 0;
}
}
/* Process buffer: emit text before tools, parse complete tool blocks. */
static void drain(mm_scan_t *s) {
for (;;) {
if (!s->in_tool) {
strip_stray_closes(s);
char *open = strstr(s->buf, MM_OPEN);
if (!open) {
/* Keep a short tail that could be a partial open tag. */
size_t keep = MM_OPEN_LEN - 1;
if (s->len <= keep)
return;
size_t emit_n = s->len - keep;
/* Don't split a UTF-8 codepoint across emit/keep. */
while (emit_n > 0 &&
((unsigned char)s->buf[emit_n] & 0xC0) == 0x80)
emit_n--;
if (emit_n == 0)
return;
emit_text(s, s->buf, emit_n);
memmove(s->buf, s->buf + emit_n, s->len - emit_n);
s->len -= emit_n;
s->buf[s->len] = 0;
return;
}
size_t before = (size_t)(open - s->buf);
if (before)
emit_text(s, s->buf, before);
/* drop open tag from buffer, enter tool mode */
size_t rest = s->len - before - MM_OPEN_LEN;
memmove(s->buf, open + MM_OPEN_LEN, rest);
s->len = rest;
s->buf[s->len] = 0;
s->in_tool = 1;
bump_pending(s);
continue;
}
/* in tool: wait for close */
char *close = strstr(s->buf, MM_CLOSE);
if (!close)
return; /* pending */
size_t inner_len = (size_t)(close - s->buf);
parse_tool_block(s, s->buf, inner_len);
size_t rest = s->len - inner_len - MM_CLOSE_LEN;
memmove(s->buf, close + MM_CLOSE_LEN, rest);
s->len = rest;
s->buf[s->len] = 0;
s->in_tool = 0;
}
}
int mm_scan_feed(mm_scan_t *s, const char *data, size_t len) {
if (!s || !data)
return -1;
if (ensure(s, len) != 0)
return -1;
memcpy(s->buf + s->len, data, len);
s->len += len;
s->buf[s->len] = 0;
bump_pending(s);
drain(s);
return 0;
}
int mm_scan_finish(mm_scan_t *s) {
if (!s)
return -1;
drain(s);
if (s->in_tool) {
/* Incomplete tool — do NOT execute; emit as visible text so UI isn't stuck */
emit_text(s, MM_OPEN, MM_OPEN_LEN);
if (s->len)
emit_text(s, s->buf, s->len);
s->len = 0;
s->in_tool = 0;
} else if (s->len) {
emit_text(s, s->buf, s->len);
s->len = 0;
}
return 0;
}
+67
View File
@@ -0,0 +1,67 @@
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MODEL_MAX_LEN 128
static int already(char out[][MODEL_MAX_LEN], int n, const char *name) {
for (int i = 0; i < n; i++) {
if (strcmp(out[i], name) == 0)
return 1;
}
return 0;
}
static int push(char out[][MODEL_MAX_LEN], int *n, int max, const char *name) {
if (!name || !name[0] || *n >= max)
return 0;
if (already(out, *n, name))
return 0;
snprintf(out[*n], MODEL_MAX_LEN, "%s", name);
(*n)++;
return 1;
}
int cst_models_list(const char *current, char out[][128], int max_out) {
if (!out || max_out <= 0)
return 0;
int n = 0;
const char *env = getenv("CST_MODELS");
if (env && env[0]) {
char buf[1024];
snprintf(buf, sizeof(buf), "%s", env);
char *p = buf;
while (*p && n < max_out) {
while (*p == ':' || *p == ' ' || *p == ',')
p++;
if (!*p)
break;
char *start = p;
while (*p && *p != ':' && *p != ',')
p++;
char save = *p;
*p = 0;
/* trim trailing spaces */
char *end = start + strlen(start);
while (end > start && (end[-1] == ' ' || end[-1] == '\t'))
*--end = 0;
push(out, &n, max_out, start);
*p = save;
if (*p)
p++;
}
} else {
push(out, &n, max_out, "MiniMax-M2.7");
push(out, &n, max_out, "MiniMax-M2.5");
push(out, &n, max_out, "gpt-4o");
push(out, &n, max_out, "gpt-4o-mini");
}
if (current && current[0])
push(out, &n, max_out, current);
return n;
}
+584
View File
@@ -0,0 +1,584 @@
#define _DEFAULT_SOURCE
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "os_compat.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <direct.h>
#include <fcntl.h>
#include <io.h>
#include <process.h>
#include <windows.h>
#else
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
void os_run_result_free(os_run_result_t *r) {
if (!r)
return;
free(r->out);
r->out = NULL;
r->out_len = 0;
}
int os_path_is_abs(const char *p) {
if (!p || !p[0])
return 0;
#ifdef _WIN32
if (p[0] == '/' || p[0] == '\\')
return 1;
if (((p[0] >= 'A' && p[0] <= 'Z') || (p[0] >= 'a' && p[0] <= 'z')) &&
p[1] == ':' && (p[2] == '/' || p[2] == '\\' || p[2] == '\0'))
return 1;
return 0;
#else
return p[0] == '/';
#endif
}
void os_path_norm_slashes(char *p) {
if (!p)
return;
for (; *p; p++) {
if (*p == '\\')
*p = '/';
}
}
int os_path_join(char *out, size_t out_sz, const char *a, const char *b) {
if (!out || out_sz < 2 || !a)
return -1;
if (!b || !b[0]) {
snprintf(out, out_sz, "%s", a);
return 0;
}
if (os_path_is_abs(b)) {
snprintf(out, out_sz, "%s", b);
return 0;
}
size_t al = strlen(a);
while (al > 0 && (a[al - 1] == '/' || a[al - 1] == '\\'))
al--;
#ifdef _WIN32
int n = snprintf(out, out_sz, "%.*s\\%s", (int)al, a, b);
#else
int n = snprintf(out, out_sz, "%.*s/%s", (int)al, a, b);
#endif
if (n < 0 || (size_t)n >= out_sz)
return -1;
return 0;
}
int os_realpath(const char *path, char *out, size_t out_sz) {
if (!path || !out || out_sz < 2)
return -1;
#ifdef _WIN32
char buf[OS_PATH_MAX];
DWORD n = GetFullPathNameA(path, (DWORD)sizeof(buf), buf, NULL);
if (n == 0 || n >= sizeof(buf))
return -1;
/* Prefer forward slashes internally for sandbox compares. */
os_path_norm_slashes(buf);
if (strlen(buf) + 1 > out_sz)
return -1;
memcpy(out, buf, strlen(buf) + 1);
/* Lowercase drive letter for stable compares */
if (out[0] && out[1] == ':') {
if (out[0] >= 'A' && out[0] <= 'Z')
out[0] = (char)(out[0] - 'A' + 'a');
}
return 0;
#else
char *r = realpath(path, NULL);
if (!r)
return -1;
if (strlen(r) + 1 > out_sz) {
free(r);
return -1;
}
memcpy(out, r, strlen(r) + 1);
free(r);
return 0;
#endif
}
int os_path_under(const char *root, const char *path) {
if (!root || !path)
return 0;
size_t rl = strlen(root);
if (strncmp(path, root, rl) != 0)
return 0;
if (path[rl] == '\0')
return 1;
#ifdef _WIN32
return path[rl] == '/' || path[rl] == '\\';
#else
return path[rl] == '/';
#endif
}
int os_mkdir(const char *path, int mode) {
if (!path)
return -1;
#ifdef _WIN32
(void)mode;
if (_mkdir(path) != 0 && errno != EEXIST)
return -1;
return 0;
#else
if (mkdir(path, (mode_t)mode) != 0 && errno != EEXIST)
return -1;
return 0;
#endif
}
int os_config_dir(char *out, size_t out_sz) {
if (!out || out_sz < 8)
return -1;
#ifdef _WIN32
const char *base = getenv("APPDATA");
if (!base || !base[0])
base = getenv("LOCALAPPDATA");
if (!base || !base[0])
return -1;
int n = snprintf(out, out_sz, "%s\\cst-agent", base);
#else
const char *home = getenv("HOME");
if (!home || !home[0])
return -1;
int n = snprintf(out, out_sz, "%s/.config/cst-agent", home);
#endif
if (n < 0 || (size_t)n >= out_sz)
return -1;
return 0;
}
int os_temp_dir(char *out, size_t out_sz) {
if (!out || out_sz < 2)
return -1;
#ifdef _WIN32
DWORD n = GetTempPathA((DWORD)out_sz, out);
if (n == 0 || n >= out_sz)
return -1;
os_path_norm_slashes(out);
size_t L = strlen(out);
while (L > 0 && out[L - 1] == '/')
out[--L] = 0;
return 0;
#else
const char *t = getenv("TMPDIR");
if (!t || !t[0])
t = "/tmp";
snprintf(out, out_sz, "%s", t);
return 0;
#endif
}
int os_mkstemp(char *out, size_t out_sz, const char *prefix) {
if (!out || out_sz < 16)
return -1;
char dir[OS_PATH_MAX];
if (os_temp_dir(dir, sizeof(dir)) != 0)
return -1;
if (!prefix)
prefix = "cst";
#ifdef _WIN32
char name[MAX_PATH];
if (GetTempFileNameA(dir, prefix, 0, name) == 0)
return -1;
os_path_norm_slashes(name);
if (strlen(name) + 1 > out_sz)
return -1;
memcpy(out, name, strlen(name) + 1);
int fd = _open(out, _O_RDWR | _O_BINARY);
return fd;
#else
int n = snprintf(out, out_sz, "%s/%s-XXXXXX", dir, prefix);
if (n < 0 || (size_t)n >= out_sz)
return -1;
int fd = mkstemp(out);
return fd;
#endif
}
/* Optional: main() may set this for Termux / argv0 fallback when
* /proc/self/exe is unavailable. */
static char g_argv0_dir[OS_PATH_MAX];
void os_set_prog_path(const char *argv0) {
g_argv0_dir[0] = 0;
if (!argv0 || !argv0[0])
return;
char buf[OS_PATH_MAX];
snprintf(buf, sizeof(buf), "%s", argv0);
os_path_norm_slashes(buf);
char *slash = strrchr(buf, '/');
if (!slash)
return;
*slash = 0;
/* Relative "./foo" → keep as-is; caller may pass absolute. */
snprintf(g_argv0_dir, sizeof(g_argv0_dir), "%s", buf);
}
int os_exe_dir(char *out, size_t out_sz) {
if (!out || out_sz < 2)
return -1;
const char *env = getenv("CST_EXE_DIR");
if (env && env[0]) {
snprintf(out, out_sz, "%s", env);
return 0;
}
#ifdef _WIN32
char buf[OS_PATH_MAX];
DWORD n = GetModuleFileNameA(NULL, buf, (DWORD)sizeof(buf));
if (n == 0 || n >= sizeof(buf))
return -1;
os_path_norm_slashes(buf);
char *slash = strrchr(buf, '/');
if (!slash)
return -1;
*slash = 0;
if (strlen(buf) + 1 > out_sz)
return -1;
memcpy(out, buf, strlen(buf) + 1);
return 0;
#else
char buf[OS_PATH_MAX];
ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
if (n >= 0) {
buf[n] = 0;
char *slash = strrchr(buf, '/');
if (slash) {
*slash = 0;
if (strlen(buf) + 1 <= out_sz) {
memcpy(out, buf, strlen(buf) + 1);
return 0;
}
}
}
/* Termux / restricted Android: fall back to argv0 dir. */
if (g_argv0_dir[0]) {
snprintf(out, out_sz, "%s", g_argv0_dir);
return 0;
}
return -1;
#endif
}
static int file_exists(const char *p) {
#ifdef _WIN32
DWORD a = GetFileAttributesA(p);
return a != INVALID_FILE_ATTRIBUTES && !(a & FILE_ATTRIBUTE_DIRECTORY);
#else
return access(p, X_OK) == 0 || access(p, F_OK) == 0;
#endif
}
int os_resolve_shell(char *out, size_t out_sz) {
if (!out || out_sz < 2)
return -1;
const char *env = getenv("CST_SHELL");
if (env && env[0]) {
snprintf(out, out_sz, "%s", env);
return 0;
}
char dir[OS_PATH_MAX];
if (os_exe_dir(dir, sizeof(dir)) == 0) {
#ifdef _WIN32
char cand[OS_PATH_MAX];
if (os_path_join(cand, sizeof(cand), dir, "busybox.exe") == 0 &&
file_exists(cand)) {
snprintf(out, out_sz, "%s", cand);
return 0;
}
#else
char cand[OS_PATH_MAX];
if (os_path_join(cand, sizeof(cand), dir, "busybox") == 0 &&
file_exists(cand)) {
snprintf(out, out_sz, "%s", cand);
return 0;
}
#endif
}
#ifdef _WIN32
snprintf(out, out_sz, "%s", "busybox.exe");
return 0;
#else
/* Termux: $PREFIX/bin/sh */
{
const char *prefix = getenv("PREFIX");
if (prefix && prefix[0]) {
char cand[OS_PATH_MAX];
if (os_path_join(cand, sizeof(cand), prefix, "bin/sh") == 0 &&
file_exists(cand)) {
snprintf(out, out_sz, "%s", cand);
return 0;
}
}
}
if (file_exists("/bin/sh")) {
snprintf(out, out_sz, "%s", "/bin/sh");
return 0;
}
if (file_exists("/system/bin/sh")) {
snprintf(out, out_sz, "%s", "/system/bin/sh");
return 0;
}
snprintf(out, out_sz, "%s", "sh");
return 0;
#endif
}
#ifdef _WIN32
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
os_run_result_t *r) {
if (!argv || !argv[0] || !r)
return -1;
if (max_out == 0)
max_out = 512 * 1024;
memset(r, 0, sizeof(*r));
char cmdline[8192];
size_t pos = 0;
cmdline[0] = 0;
for (int i = 0; argv[i]; i++) {
const char *a = argv[i];
int need_q = strchr(a, ' ') || strchr(a, '\t') || strchr(a, '"');
if (i && pos + 1 < sizeof(cmdline))
cmdline[pos++] = ' ';
if (need_q) {
if (pos + 1 < sizeof(cmdline))
cmdline[pos++] = '"';
for (const char *p = a; *p && pos + 2 < sizeof(cmdline); p++) {
if (*p == '"') {
cmdline[pos++] = '\\';
cmdline[pos++] = '"';
} else {
cmdline[pos++] = *p;
}
}
if (pos + 1 < sizeof(cmdline))
cmdline[pos++] = '"';
} else {
size_t L = strlen(a);
if (pos + L >= sizeof(cmdline))
break;
memcpy(cmdline + pos, a, L);
pos += L;
}
cmdline[pos] = 0;
}
SECURITY_ATTRIBUTES sa = {0};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE rd = NULL, wr = NULL;
if (!CreatePipe(&rd, &wr, &sa, 0))
return -1;
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOA si = {0};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = wr;
si.hStdError = wr;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION pi = {0};
char *cmd_mut = _strdup(cmdline);
if (!cmd_mut) {
CloseHandle(rd);
CloseHandle(wr);
return -1;
}
BOOL ok = CreateProcessA(NULL, cmd_mut, NULL, NULL, TRUE, 0, NULL,
cwd && cwd[0] ? cwd : NULL, &si, &pi);
free(cmd_mut);
CloseHandle(wr);
if (!ok) {
CloseHandle(rd);
return -1;
}
size_t cap = 4096, len = 0;
char *buf = malloc(cap);
if (!buf) {
CloseHandle(rd);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return -1;
}
for (;;) {
char tmp[1024];
DWORD n = 0;
if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
break;
if (len + n + 1 > cap) {
size_t nc = cap * 2;
if (nc < len + n + 1)
nc = len + n + 1;
char *nb = realloc(buf, nc);
if (!nb)
break;
buf = nb;
cap = nc;
}
memcpy(buf + len, tmp, n);
len += n;
if (len >= max_out)
break;
}
CloseHandle(rd);
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
buf[len] = 0;
r->out = buf;
r->out_len = len;
r->exit_code = (int)code;
return 0;
}
#else
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
os_run_result_t *r) {
if (!argv || !argv[0] || !r)
return -1;
if (max_out == 0)
max_out = 512 * 1024;
memset(r, 0, sizeof(*r));
int pipefd[2];
if (pipe(pipefd) != 0)
return -1;
pid_t pid = fork();
if (pid < 0) {
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
if (pid == 0) {
close(pipefd[0]);
dup2(pipefd[1], STDOUT_FILENO);
dup2(pipefd[1], STDERR_FILENO);
close(pipefd[1]);
if (cwd && cwd[0] && chdir(cwd) != 0)
_exit(127);
execvp(argv[0], (char *const *)argv);
_exit(127);
}
close(pipefd[1]);
size_t cap = 4096, len = 0;
char *buf = malloc(cap);
if (!buf) {
close(pipefd[0]);
waitpid(pid, NULL, 0);
return -1;
}
for (;;) {
if (len + 1024 > cap) {
cap *= 2;
char *nb = realloc(buf, cap);
if (!nb)
break;
buf = nb;
}
ssize_t n = read(pipefd[0], buf + len, 1024);
if (n < 0) {
if (errno == EINTR)
continue;
break;
}
if (n == 0)
break;
len += (size_t)n;
if (len >= max_out)
break;
}
close(pipefd[0]);
int status = 0;
waitpid(pid, &status, 0);
buf[len] = 0;
r->out = buf;
r->out_len = len;
r->exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 1;
return 0;
}
#endif
int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out,
os_run_result_t *r) {
if (!cmd || !r)
return -1;
char shell[OS_PATH_MAX];
if (os_resolve_shell(shell, sizeof(shell)) != 0)
return -1;
/* busybox: busybox sh -c cmd ; /bin/sh: sh -c cmd */
int is_busybox = 0;
{
const char *base = shell;
const char *s = strrchr(shell, '/');
#ifdef _WIN32
const char *s2 = strrchr(shell, '\\');
if (s2 && (!s || s2 > s))
s = s2;
#endif
if (s)
base = s + 1;
if (strncmp(base, "busybox", 7) == 0)
is_busybox = 1;
}
if (is_busybox) {
const char *argv[] = {shell, "sh", "-c", cmd, NULL};
return os_run_capture(cwd, argv, max_out, r);
} else {
const char *argv[] = {shell, "-c", cmd, NULL};
return os_run_capture(cwd, argv, max_out, r);
}
}
void os_localtime(const time_t *t, struct tm *out) {
if (!t || !out)
return;
#ifdef _WIN32
localtime_s(out, t);
#else
localtime_r(t, out);
#endif
}
int os_getpid(void) {
#ifdef _WIN32
return (int)GetCurrentProcessId();
#else
return (int)getpid();
#endif
}
int os_chmod_private(const char *path) {
if (!path)
return -1;
#ifdef _WIN32
(void)path;
return 0;
#else
return chmod(path, 0600);
#endif
}
+61
View File
@@ -0,0 +1,61 @@
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cst_agent.h"
#include "os_compat.h"
int cst_sandbox_resolve(const char *workspace_root, const char *rel_or_abs,
char *out, size_t out_sz) {
if (!workspace_root || !rel_or_abs || !out || out_sz < 2)
return -1;
char root_real[OS_PATH_MAX];
if (os_realpath(workspace_root, root_real, sizeof(root_real)) != 0)
return -1;
os_path_norm_slashes(root_real);
size_t root_len = strlen(root_real);
char candidate[OS_PATH_MAX];
if (os_path_is_abs(rel_or_abs)) {
snprintf(candidate, sizeof(candidate), "%s", rel_or_abs);
} else if (os_path_join(candidate, sizeof(candidate), root_real, rel_or_abs) !=
0) {
return -1;
}
os_path_norm_slashes(candidate);
char resolved[OS_PATH_MAX];
if (os_realpath(candidate, resolved, sizeof(resolved)) == 0) {
os_path_norm_slashes(resolved);
if (!os_path_under(root_real, resolved))
return -1;
if (strlen(resolved) + 1 > out_sz)
return -1;
memcpy(out, resolved, strlen(resolved) + 1);
return 0;
}
/* Parent exists, leaf may be new file. */
char tmp[OS_PATH_MAX];
snprintf(tmp, sizeof(tmp), "%s", candidate);
char *slash = strrchr(tmp, '/');
if (!slash)
return -1;
*slash = 0;
const char *base = slash + 1;
char parent_real[OS_PATH_MAX];
if (os_realpath(tmp, parent_real, sizeof(parent_real)) != 0)
return -1;
os_path_norm_slashes(parent_real);
if (!os_path_under(root_real, parent_real) &&
strcmp(parent_real, root_real) != 0)
return -1;
(void)root_len;
int n = snprintf(out, out_sz, "%s/%s", parent_real, base);
if (n < 0 || (size_t)n >= out_sz)
return -1;
return 0;
}
+363
View File
@@ -0,0 +1,363 @@
#define _DEFAULT_SOURCE
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
typedef long long cst_ssize_t;
static cst_ssize_t cst_getline(char **lineptr, size_t *n, FILE *stream) {
if (!lineptr || !n || !stream)
return -1;
if (!*lineptr || *n == 0) {
*n = 256;
*lineptr = (char *)malloc(*n);
if (!*lineptr)
return -1;
}
size_t len = 0;
int c = 0;
while ((c = fgetc(stream)) != EOF) {
if (len + 2 > *n) {
size_t nn = (*n) * 2;
char *nb = (char *)realloc(*lineptr, nn);
if (!nb)
return -1;
*lineptr = nb;
*n = nn;
}
(*lineptr)[len++] = (char)c;
if (c == '\n')
break;
}
if (len == 0 && c == EOF)
return -1;
(*lineptr)[len] = 0;
return (cst_ssize_t)len;
}
#define getline cst_getline
#endif
struct cst_session {
char path[4096];
cst_message_t *msgs;
int nmsgs;
int cap;
FILE *fp;
};
static char *xstrdup(const char *s) {
if (!s)
return NULL;
size_t n = strlen(s);
char *p = malloc(n + 1);
if (!p)
return NULL;
memcpy(p, s, n + 1);
return p;
}
static const char *role_str(cst_role_t r) {
switch (r) {
case CST_ROLE_SYSTEM:
return "system";
case CST_ROLE_USER:
return "user";
case CST_ROLE_ASSISTANT:
return "assistant";
case CST_ROLE_TOOL:
return "tool";
}
return "user";
}
static cst_role_t role_from(const char *s) {
if (strcmp(s, "system") == 0)
return CST_ROLE_SYSTEM;
if (strcmp(s, "assistant") == 0)
return CST_ROLE_ASSISTANT;
if (strcmp(s, "tool") == 0)
return CST_ROLE_TOOL;
return CST_ROLE_USER;
}
static int json_escape_write(FILE *fp, const char *s) {
fputc('"', fp);
for (; s && *s; s++) {
unsigned char c = (unsigned char)*s;
if (c == '"' || c == '\\') {
fputc('\\', fp);
fputc(c, fp);
} else if (c == '\n') {
fputs("\\n", fp);
} else if (c == '\r') {
fputs("\\r", fp);
} else if (c == '\t') {
fputs("\\t", fp);
} else if (c < 0x20) {
fprintf(fp, "\\u%04x", c);
} else {
fputc(c, fp);
}
}
fputc('"', fp);
return 0;
}
/* Minimal extract "key":"value" from a single JSONL object line. */
static int extract_str(const char *line, const char *key, char *out,
size_t out_sz) {
char pat[64];
snprintf(pat, sizeof(pat), "\"%s\":\"", key);
const char *p = strstr(line, pat);
if (!p)
return -1;
p += strlen(pat);
size_t o = 0;
while (*p && o + 1 < out_sz) {
if (*p == '\\' && p[1]) {
p++;
if (*p == 'n')
out[o++] = '\n';
else if (*p == 'r')
out[o++] = '\r';
else if (*p == 't')
out[o++] = '\t';
else
out[o++] = *p;
p++;
continue;
}
if (*p == '"') {
out[o] = 0;
return 0;
}
out[o++] = *p++;
}
return -1;
}
static int session_grow(cst_session_t *s) {
if (s->nmsgs < s->cap)
return 0;
int ncap = s->cap ? s->cap * 2 : 32;
cst_message_t *nm = realloc(s->msgs, (size_t)ncap * sizeof(cst_message_t));
if (!nm)
return -1;
s->msgs = nm;
s->cap = ncap;
return 0;
}
static int session_push_mem(cst_session_t *s, cst_role_t role, const char *content,
const char *tool_id, const char *tool_name,
const char *tool_calls_json) {
if (session_grow(s) != 0)
return -1;
cst_message_t *d = &s->msgs[s->nmsgs];
memset(d, 0, sizeof(*d));
d->role = role;
d->content = xstrdup(content ? content : "");
if (!d->content)
return -1;
if (tool_id)
snprintf(d->tool_call_id, sizeof(d->tool_call_id), "%s", tool_id);
if (tool_name)
snprintf(d->tool_name, sizeof(d->tool_name), "%s", tool_name);
if (tool_calls_json && tool_calls_json[0])
d->tool_calls_json = xstrdup(tool_calls_json);
s->nmsgs++;
return 0;
}
static void load_existing(cst_session_t *s) {
FILE *rp = fopen(s->path, "r");
if (!rp)
return;
char *line = NULL;
size_t cap = 0;
while (getline(&line, &cap, rp) != -1) {
char role[32] = {0};
char *content = malloc(cap > 8 ? cap : 4096);
char tid[CTL_MAX_ID] = {0};
char tname[CTL_MAX_NAME] = {0};
if (!content)
continue;
content[0] = 0;
if (extract_str(line, "role", role, sizeof(role)) != 0) {
free(content);
continue;
}
/* content may be large — reuse line extract into growing buffer */
free(content);
content = malloc(strlen(line) + 1);
if (!content)
continue;
if (extract_str(line, "content", content, strlen(line) + 1) != 0) {
free(content);
continue;
}
extract_str(line, "tool_call_id", tid, sizeof(tid));
extract_str(line, "tool_name", tname, sizeof(tname));
char *tcj = malloc(strlen(line) + 1);
const char *tcjp = NULL;
if (tcj) {
tcj[0] = 0;
if (extract_str(line, "tool_calls_json", tcj, strlen(line) + 1) == 0)
tcjp = tcj;
}
session_push_mem(s, role_from(role), content, tid[0] ? tid : NULL,
tname[0] ? tname : NULL, tcjp);
free(tcj);
free(content);
}
free(line);
fclose(rp);
}
cst_session_t *cst_session_open(const char *path) {
cst_session_t *s = calloc(1, sizeof(*s));
if (!s)
return NULL;
snprintf(s->path, sizeof(s->path), "%s", path ? path : "session.jsonl");
load_existing(s);
s->fp = fopen(s->path, "a+");
if (!s->fp) {
for (int i = 0; i < s->nmsgs; i++) {
free(s->msgs[i].content);
free(s->msgs[i].tool_calls_json);
}
free(s->msgs);
free(s);
return NULL;
}
if (!s->msgs) {
s->cap = 32;
s->msgs = calloc((size_t)s->cap, sizeof(cst_message_t));
if (!s->msgs) {
fclose(s->fp);
free(s);
return NULL;
}
}
return s;
}
void cst_session_close(cst_session_t *s) {
if (!s)
return;
for (int i = 0; i < s->nmsgs; i++) {
free(s->msgs[i].content);
free(s->msgs[i].tool_calls_json);
}
free(s->msgs);
if (s->fp)
fclose(s->fp);
free(s);
}
int cst_session_append(cst_session_t *s, const cst_message_t *msg) {
if (!s || !msg)
return -1;
if (session_push_mem(s, msg->role, msg->content, msg->tool_call_id,
msg->tool_name, msg->tool_calls_json) != 0)
return -1;
fprintf(s->fp, "{\"role\":");
json_escape_write(s->fp, role_str(msg->role));
fprintf(s->fp, ",\"content\":");
json_escape_write(s->fp, msg->content ? msg->content : "");
if (msg->tool_call_id[0]) {
fprintf(s->fp, ",\"tool_call_id\":");
json_escape_write(s->fp, msg->tool_call_id);
}
if (msg->tool_name[0]) {
fprintf(s->fp, ",\"tool_name\":");
json_escape_write(s->fp, msg->tool_name);
}
if (msg->tool_calls_json && msg->tool_calls_json[0]) {
fprintf(s->fp, ",\"tool_calls_json\":");
json_escape_write(s->fp, msg->tool_calls_json);
}
fprintf(s->fp, "}\n");
fflush(s->fp);
return 0;
}
int cst_session_count(const cst_session_t *s) {
return s ? s->nmsgs : 0;
}
const cst_message_t *cst_session_get(const cst_session_t *s, int i) {
if (!s || i < 0 || i >= s->nmsgs)
return NULL;
return &s->msgs[i];
}
const char *cst_session_path(const cst_session_t *s) {
return s ? s->path : NULL;
}
int cst_session_clear(cst_session_t *s, int keep_system) {
if (!s)
return -1;
cst_message_t sys = {0};
int have_sys = 0;
if (keep_system) {
for (int i = 0; i < s->nmsgs; i++) {
if (s->msgs[i].role == CST_ROLE_SYSTEM) {
sys.role = CST_ROLE_SYSTEM;
sys.content = xstrdup(s->msgs[i].content ? s->msgs[i].content : "");
have_sys = 1;
break;
}
}
}
for (int i = 0; i < s->nmsgs; i++) {
free(s->msgs[i].content);
free(s->msgs[i].tool_calls_json);
}
s->nmsgs = 0;
if (s->fp) {
fclose(s->fp);
s->fp = NULL;
}
s->fp = fopen(s->path, "w+");
if (!s->fp) {
if (have_sys)
free(sys.content);
return -1;
}
if (have_sys) {
cst_session_append(s, &sys);
free(sys.content);
}
return 0;
}
int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n) {
if (!s || n < 0)
return -1;
for (int i = 0; i < s->nmsgs; i++) {
free(s->msgs[i].content);
free(s->msgs[i].tool_calls_json);
}
s->nmsgs = 0;
if (s->fp) {
fclose(s->fp);
s->fp = NULL;
}
s->fp = fopen(s->path, "w+");
if (!s->fp)
return -1;
for (int i = 0; i < n; i++) {
if (cst_session_append(s, &msgs[i]) != 0)
return -1;
}
return 0;
}
+784
View File
@@ -0,0 +1,784 @@
#define _DEFAULT_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cst_agent.h"
#include "os_compat.h"
#ifndef _WIN32
#include <unistd.h>
#else
#include <io.h>
#include <fcntl.h>
#endif
void cst_tool_result_free(cst_tool_result_t *r) {
if (!r)
return;
free(r->out);
r->out = NULL;
r->out_len = 0;
}
static const char *arg_get(const ctl_tool_call_t *call, const char *key) {
for (int i = 0; i < call->nargs; i++) {
if (strcmp(call->args[i].key, key) == 0)
return call->args[i].value ? call->args[i].value : "";
}
return NULL;
}
static int set_result(cst_tool_result_t *r, const char *s, int code) {
cst_tool_result_free(r);
size_t n = s ? strlen(s) : 0;
r->out = malloc(n + 1);
if (!r->out)
return -1;
if (n)
memcpy(r->out, s, n);
r->out[n] = 0;
r->out_len = n;
r->exit_code = code;
return 0;
}
/* Read whole file into malloc'd buffer; *out_len set. Returns NULL if missing. */
static char *slurp_file(const char *full, size_t *out_len) {
*out_len = 0;
FILE *fp = fopen(full, "rb");
if (!fp)
return NULL;
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return NULL;
}
long sz = ftell(fp);
if (sz < 0) {
fclose(fp);
return NULL;
}
rewind(fp);
char *buf = malloc((size_t)sz + 1);
if (!buf) {
fclose(fp);
return NULL;
}
size_t n = fread(buf, 1, (size_t)sz, fp);
fclose(fp);
buf[n] = 0;
*out_len = n;
return buf;
}
#define DIFF_OUT_CAP (48 * 1024)
/* OpenCode-style: always surface a unified diff after write/patch. */
static char *make_unified_diff(const char *relpath, const char *oldc, size_t olen,
const char *newc, size_t nlen) {
char t0[OS_PATH_MAX], t1[OS_PATH_MAX];
int fd0 = os_mkstemp(t0, sizeof(t0), "cstda");
int fd1 = os_mkstemp(t1, sizeof(t1), "cstdb");
if (fd0 < 0 || fd1 < 0) {
if (fd0 >= 0) {
#ifdef _WIN32
_close(fd0);
_unlink(t0);
#else
close(fd0);
unlink(t0);
#endif
}
if (fd1 >= 0) {
#ifdef _WIN32
_close(fd1);
_unlink(t1);
#else
close(fd1);
unlink(t1);
#endif
}
return NULL;
}
if (olen) {
#ifdef _WIN32
(void)_write(fd0, oldc, (unsigned)olen);
#else
(void)write(fd0, oldc, olen);
#endif
}
if (nlen) {
#ifdef _WIN32
(void)_write(fd1, newc, (unsigned)nlen);
#else
(void)write(fd1, newc, nlen);
#endif
}
#ifdef _WIN32
_close(fd0);
_close(fd1);
#else
close(fd0);
close(fd1);
#endif
const char *label = relpath && relpath[0] ? relpath : "file";
char cmd[OS_PATH_MAX * 2 + 256];
snprintf(cmd, sizeof(cmd),
"diff -u --label a/%s --label b/%s \"%s\" \"%s\"", label, label, t0,
t1);
os_run_result_t rr = {0};
int rc = os_run_shell_c(NULL, cmd, DIFF_OUT_CAP, &rr);
#ifdef _WIN32
_unlink(t0);
_unlink(t1);
#else
unlink(t0);
unlink(t1);
#endif
if (rc != 0) {
os_run_result_free(&rr);
return strdup("(no textual diff — diff unavailable)\n");
}
/* diff returns 1 when files differ — still success for our purposes */
if (!rr.out || rr.out_len == 0) {
os_run_result_free(&rr);
return strdup("(no textual diff — identical or binary)\n");
}
char *out = rr.out;
rr.out = NULL;
size_t len = rr.out_len;
os_run_result_free(&rr);
if (len >= DIFF_OUT_CAP - 64) {
snprintf(out + DIFF_OUT_CAP - 64, 64, "\n…(diff truncated)\n");
}
return out;
}
static int set_result_with_diff(cst_tool_result_t *r, const char *relpath,
const char *oldc, size_t olen, const char *newc,
size_t nlen, int code) {
char *diff = make_unified_diff(relpath, oldc ? oldc : "", olen, newc ? newc : "",
nlen);
char hdr[256];
snprintf(hdr, sizeof(hdr), "wrote %s (%zu bytes)\n",
relpath ? relpath : "?", nlen);
size_t hlen = strlen(hdr);
size_t dlen = diff ? strlen(diff) : 0;
char *buf = malloc(hlen + dlen + 1);
if (!buf) {
free(diff);
return set_result(r, "ok", code);
}
memcpy(buf, hdr, hlen);
if (diff && dlen)
memcpy(buf + hlen, diff, dlen);
buf[hlen + dlen] = 0;
free(diff);
cst_tool_result_free(r);
r->out = buf;
r->out_len = hlen + dlen;
r->exit_code = code;
return 0;
}
int cst_tool_read_file(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r) {
const char *path = arg_get(call, "path");
if (!path)
return set_result(r, "error: missing path", 1);
char full[4096];
if (cst_sandbox_resolve(workspace_root, path, full, sizeof(full)) != 0)
return set_result(r, "error: path outside workspace", 1);
FILE *fp = fopen(full, "rb");
if (!fp)
return set_result(r, "error: cannot open file", 1);
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return set_result(r, "error: seek failed", 1);
}
long sz = ftell(fp);
if (sz < 0 || sz > 2 * 1024 * 1024) {
fclose(fp);
return set_result(r, "error: file too large or empty seek", 1);
}
rewind(fp);
char *buf = malloc((size_t)sz + 1);
if (!buf) {
fclose(fp);
return set_result(r, "error: oom", 1);
}
size_t n = fread(buf, 1, (size_t)sz, fp);
fclose(fp);
buf[n] = 0;
cst_tool_result_free(r);
r->out = buf;
r->out_len = n;
r->exit_code = 0;
return 0;
}
int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r) {
const char *path = arg_get(call, "path");
const char *content = arg_get(call, "content");
if (!path || !content)
return set_result(r, "error: missing path or content", 1);
char full[4096];
if (cst_sandbox_resolve(workspace_root, path, full, sizeof(full)) != 0)
return set_result(r, "error: path outside workspace", 1);
size_t olen = 0;
char *oldc = slurp_file(full, &olen);
FILE *fp = fopen(full, "wb");
if (!fp) {
free(oldc);
return set_result(r, "error: cannot write file", 1);
}
size_t n = strlen(content);
if (fwrite(content, 1, n, fp) != n) {
fclose(fp);
free(oldc);
return set_result(r, "error: write failed", 1);
}
fclose(fp);
int rc = set_result_with_diff(r, path, oldc, olen, content, n, 0);
free(oldc);
return rc;
}
int cst_tool_run_shell(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r) {
const char *cmd = arg_get(call, "cmd");
if (!cmd)
cmd = arg_get(call, "command");
if (!cmd)
return set_result(r, "error: missing cmd", 1);
char root_real[OS_PATH_MAX];
if (os_realpath(workspace_root, root_real, sizeof(root_real)) != 0)
return set_result(r, "error: bad workspace", 1);
os_run_result_t rr = {0};
if (os_run_shell_c(root_real, cmd, 512 * 1024, &rr) != 0) {
os_run_result_free(&rr);
return set_result(r, "error: shell exec failed", 1);
}
cst_tool_result_free(r);
r->out = rr.out;
r->out_len = rr.out_len;
r->exit_code = rr.exit_code;
rr.out = NULL;
os_run_result_free(&rr);
return 0;
}
/* Minimal unified diff apply: only support *** Update File: / write via
* apply_patch tool with arg path + content for MVP simplicity is write_file.
* For apply_patch: expect arg "patch" with a simple format:
* *** Update File: <relpath>
* <full new file content>
* Or classic unified diff for single file with ---/+++ and @@ hunks.
*/
static int apply_full_replace(const char *workspace_root, const char *path,
const char *content, cst_tool_result_t *r) {
ctl_tool_call_t fake = {0};
snprintf(fake.name, sizeof(fake.name), "write_file");
fake.nargs = 2;
snprintf(fake.args[0].key, sizeof(fake.args[0].key), "path");
fake.args[0].value = (char *)path;
fake.args[0].value_len = strlen(path);
snprintf(fake.args[1].key, sizeof(fake.args[1].key), "content");
fake.args[1].value = (char *)content;
fake.args[1].value_len = strlen(content);
return cst_tool_write_file(workspace_root, &fake, r);
}
/* Apply one complete unified-diff hunk to a malloc'd file buffer.
* Returns 0 on success. Very small subset: context/add/remove lines. */
static int apply_hunk(char **file, size_t *flen, const char *hunk,
size_t hunk_len) {
/* Find @@ line */
const char *p = hunk;
const char *end = hunk + hunk_len;
while (p < end && !(*p == '@' && p + 1 < end && p[1] == '@')) {
while (p < end && *p != '\n')
p++;
if (p < end)
p++;
}
if (p >= end)
return -1;
while (p < end && *p != '\n')
p++;
if (p < end)
p++;
/* Parse old file into lines */
size_t nlines = 0;
for (size_t i = 0; i < *flen; i++)
if ((*file)[i] == '\n')
nlines++;
if (*flen > 0 && (*file)[*flen - 1] != '\n')
nlines++;
char **lines = calloc(nlines + 64, sizeof(char *));
size_t *llens = calloc(nlines + 64, sizeof(size_t));
if (!lines || !llens) {
free(lines);
free(llens);
return -1;
}
size_t li = 0, start = 0;
for (size_t i = 0; i <= *flen; i++) {
if (i == *flen || (*file)[i] == '\n') {
size_t L = i - start;
lines[li] = malloc(L + 1);
if (!lines[li]) {
for (size_t k = 0; k < li; k++)
free(lines[k]);
free(lines);
free(llens);
return -1;
}
memcpy(lines[li], *file + start, L);
lines[li][L] = 0;
llens[li] = L;
li++;
start = i + 1;
}
}
size_t cur = 0;
/* Walk hunk body */
while (p < end) {
char tag = *p;
const char *ls = p + 1;
const char *le = ls;
while (le < end && *le != '\n')
le++;
size_t L = (size_t)(le - ls);
if (tag == ' ' || tag == '-') {
if (cur >= li || llens[cur] != L || memcmp(lines[cur], ls, L) != 0) {
for (size_t k = 0; k < li; k++)
free(lines[k]);
free(lines);
free(llens);
return -1;
}
if (tag == '-') {
free(lines[cur]);
memmove(&lines[cur], &lines[cur + 1], (li - cur - 1) * sizeof(char *));
memmove(&llens[cur], &llens[cur + 1], (li - cur - 1) * sizeof(size_t));
li--;
} else {
cur++;
}
} else if (tag == '+') {
char *nl = malloc(L + 1);
if (!nl) {
for (size_t k = 0; k < li; k++)
free(lines[k]);
free(lines);
free(llens);
return -1;
}
memcpy(nl, ls, L);
nl[L] = 0;
lines = realloc(lines, (li + 2) * sizeof(char *));
llens = realloc(llens, (li + 2) * sizeof(size_t));
memmove(&lines[cur + 1], &lines[cur], (li - cur) * sizeof(char *));
memmove(&llens[cur + 1], &llens[cur], (li - cur) * sizeof(size_t));
lines[cur] = nl;
llens[cur] = L;
li++;
cur++;
} else if (tag == '\\') {
/* "\ No newline at end of file" — ignore */
} else {
for (size_t k = 0; k < li; k++)
free(lines[k]);
free(lines);
free(llens);
return -1;
}
p = (le < end) ? le + 1 : end;
}
/* rebuild */
size_t total = 0;
for (size_t k = 0; k < li; k++)
total += llens[k] + 1;
char *out = malloc(total + 1);
if (!out) {
for (size_t k = 0; k < li; k++)
free(lines[k]);
free(lines);
free(llens);
return -1;
}
size_t o = 0;
for (size_t k = 0; k < li; k++) {
memcpy(out + o, lines[k], llens[k]);
o += llens[k];
out[o++] = '\n';
free(lines[k]);
}
free(lines);
free(llens);
free(*file);
*file = out;
*flen = o;
return 0;
}
int cst_tool_apply_patch(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r) {
const char *patch = arg_get(call, "patch");
if (!patch)
return set_result(r, "error: missing patch", 1);
/* Full-file replace shorthand */
if (strncmp(patch, "*** Update File: ", 17) == 0) {
const char *nl = strchr(patch, '\n');
if (!nl)
return set_result(r, "error: incomplete Update File header", 1);
char path[1024];
size_t plen = (size_t)(nl - (patch + 17));
if (plen >= sizeof(path))
return set_result(r, "error: path too long", 1);
memcpy(path, patch + 17, plen);
path[plen] = 0;
char full[4096];
if (cst_sandbox_resolve(workspace_root, path, full, sizeof(full)) != 0)
return set_result(r, "error: path outside workspace", 1);
size_t olen = 0;
char *oldc = slurp_file(full, &olen);
const char *neu = nl + 1;
size_t nlen = strlen(neu);
ctl_tool_call_t fake = {0};
snprintf(fake.name, sizeof(fake.name), "write_file");
fake.nargs = 2;
snprintf(fake.args[0].key, sizeof(fake.args[0].key), "path");
fake.args[0].value = path;
fake.args[0].value_len = strlen(path);
snprintf(fake.args[1].key, sizeof(fake.args[1].key), "content");
fake.args[1].value = (char *)neu;
fake.args[1].value_len = nlen;
/* write_file already emits diff; free unused oldc */
free(oldc);
return cst_tool_write_file(workspace_root, &fake, r);
}
/* Unified diff: require complete hunks (each @@ ... until next @@ or EOF) */
const char *p = patch;
char *rel = NULL;
/* find +++ b/path */
const char *plus = strstr(p, "\n+++ ");
if (!plus)
plus = (strncmp(p, "+++ ", 4) == 0) ? p : NULL;
if (!plus)
return set_result(r, "error: missing +++ line (incomplete patch)", 1);
const char *ps = plus + (plus[0] == '\n' ? 5 : 4);
if (ps[0] == 'b' && ps[1] == '/')
ps += 2;
const char *pe = strchr(ps, '\n');
if (!pe)
return set_result(r, "error: incomplete +++ line", 1);
size_t rlen = (size_t)(pe - ps);
rel = malloc(rlen + 1);
if (!rel)
return set_result(r, "error: oom", 1);
memcpy(rel, ps, rlen);
rel[rlen] = 0;
char full[4096];
if (cst_sandbox_resolve(workspace_root, rel, full, sizeof(full)) != 0) {
free(rel);
return set_result(r, "error: path outside workspace", 1);
}
char *relpath_for_diff = rel; /* owned until return */
FILE *fp = fopen(full, "rb");
char *file = NULL;
size_t flen = 0;
if (fp) {
fseek(fp, 0, SEEK_END);
long sz = ftell(fp);
rewind(fp);
if (sz < 0) {
fclose(fp);
free(relpath_for_diff);
return set_result(r, "error: ftell", 1);
}
file = malloc((size_t)sz + 1);
if (!file) {
fclose(fp);
free(relpath_for_diff);
return set_result(r, "error: oom", 1);
}
flen = fread(file, 1, (size_t)sz, fp);
fclose(fp);
file[flen] = 0;
} else {
file = calloc(1, 1);
flen = 0;
}
/* Snapshot before mutating hunks. */
char *old_snapshot = NULL;
size_t old_len = flen;
if (flen) {
old_snapshot = malloc(flen + 1);
if (old_snapshot) {
memcpy(old_snapshot, file, flen);
old_snapshot[flen] = 0;
}
} else {
old_snapshot = calloc(1, 1);
old_len = 0;
}
/* Split into hunks starting at @@ */
const char *h = strstr(pe, "\n@@");
if (!h)
h = strstr(pe, "@@");
if (!h) {
free(file);
free(old_snapshot);
free(relpath_for_diff);
return set_result(r, "error: no complete hunk", 1);
}
if (*h == '\n')
h++;
while (h && *h == '@') {
const char *next = strstr(h + 2, "\n@@");
size_t hlen;
if (next) {
hlen = (size_t)(next + 1 - h);
} else {
hlen = strlen(h);
}
const char *hnl = strchr(h, '\n');
if (!hnl || hnl >= h + hlen - 1) {
free(file);
free(old_snapshot);
free(relpath_for_diff);
return set_result(r, "error: incomplete hunk (not confirmed)", 1);
}
if (apply_hunk(&file, &flen, h, hlen) != 0) {
free(file);
free(old_snapshot);
free(relpath_for_diff);
return set_result(r, "error: hunk apply failed", 1);
}
h = next ? next + 1 : NULL;
}
fp = fopen(full, "wb");
if (!fp) {
free(file);
free(old_snapshot);
free(relpath_for_diff);
return set_result(r, "error: cannot write", 1);
}
if (fwrite(file, 1, flen, fp) != flen) {
fclose(fp);
free(file);
free(old_snapshot);
free(relpath_for_diff);
return set_result(r, "error: write failed", 1);
}
fclose(fp);
/* Patch text is already a diff — show it when short. */
size_t plen = strlen(patch);
if (plen > 0 && plen < DIFF_OUT_CAP) {
char hdr[256];
snprintf(hdr, sizeof(hdr), "applied patch → %s\n", relpath_for_diff);
size_t hlen = strlen(hdr);
char *buf = malloc(hlen + plen + 1);
if (buf) {
memcpy(buf, hdr, hlen);
memcpy(buf + hlen, patch, plen);
buf[hlen + plen] = 0;
cst_tool_result_free(r);
r->out = buf;
r->out_len = hlen + plen;
r->exit_code = 0;
free(file);
free(old_snapshot);
free(relpath_for_diff);
return 0;
}
}
int rc = set_result_with_diff(r, relpath_for_diff, old_snapshot, old_len, file,
flen, 0);
free(file);
free(old_snapshot);
free(relpath_for_diff);
return rc;
}
int cst_tool_dispatch(const char *workspace_root, const ctl_tool_call_t *call,
cst_tool_result_t *r) {
if (!call || !r)
return -1;
memset(r, 0, sizeof(*r));
if (strcmp(call->name, "read_file") == 0)
return cst_tool_read_file(workspace_root, call, r);
if (strcmp(call->name, "run_shell") == 0)
return cst_tool_run_shell(workspace_root, call, r);
if (strcmp(call->name, "apply_patch") == 0)
return cst_tool_apply_patch(workspace_root, call, r);
if (strcmp(call->name, "write_file") == 0)
return cst_tool_write_file(workspace_root, call, r);
return set_result(r, "error: unknown tool", 1);
}
/* Parse JSON string at *pp (must point at '"'); advances *pp past closing quote.
* Returns malloc'd decoded value. */
static char *parse_json_string(const char **pp, size_t *out_len) {
const char *p = *pp;
if (!p || *p != '"')
return NULL;
p++;
size_t cap = 64, len = 0;
char *out = malloc(cap);
if (!out)
return NULL;
while (*p) {
char ch;
if (*p == '\\' && p[1]) {
p++;
if (*p == 'n')
ch = '\n';
else if (*p == 'r')
ch = '\r';
else if (*p == 't')
ch = '\t';
else
ch = *p;
p++;
} else if (*p == '"') {
p++;
out[len] = 0;
*pp = p;
if (out_len)
*out_len = len;
return out;
} else {
ch = *p++;
}
if (len + 2 > cap) {
cap *= 2;
char *nb = realloc(out, cap);
if (!nb) {
free(out);
return NULL;
}
out = nb;
}
out[len++] = ch;
}
free(out);
return NULL;
}
int cst_tool_call_from_native(ctl_tool_call_t *out, const char *id,
const char *name, const char *args_json) {
if (!out || !name || !name[0])
return -1;
memset(out, 0, sizeof(*out));
if (id)
snprintf(out->id, sizeof(out->id), "%s", id);
snprintf(out->name, sizeof(out->name), "%s", name);
if (!args_json)
return 0;
const char *p = args_json;
while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
p++;
if (*p != '{')
return 0;
p++;
while (*p) {
while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t' || *p == ',')
p++;
if (*p == '}' || !*p)
break;
if (*p != '"')
break;
size_t klen = 0;
char *key = parse_json_string(&p, &klen);
if (!key)
break;
while (*p == ' ' || *p == '\t')
p++;
if (*p != ':') {
free(key);
break;
}
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')
p++;
if (*p == '"') {
size_t vlen = 0;
char *val = parse_json_string(&p, &vlen);
if (!val) {
free(key);
break;
}
if (out->nargs < CTL_MAX_ARGS && klen < CTL_MAX_KEY) {
snprintf(out->args[out->nargs].key, CTL_MAX_KEY, "%s", key);
out->args[out->nargs].value = val;
out->args[out->nargs].value_len = vlen;
out->nargs++;
} else {
free(val);
}
free(key);
} else if (*p == '{' || *p == '[') {
free(key);
char open = *p;
char close = (open == '{') ? '}' : ']';
int depth = 0;
int in_str = 0;
for (; *p; p++) {
if (in_str) {
if (*p == '\\' && p[1]) {
p++;
continue;
}
if (*p == '"')
in_str = 0;
continue;
}
if (*p == '"') {
in_str = 1;
continue;
}
if (*p == open)
depth++;
else if (*p == close) {
depth--;
if (depth == 0) {
p++;
break;
}
}
}
} else {
free(key);
while (*p && *p != ',' && *p != '}')
p++;
}
}
return 0;
}
+2761
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
#include "cst_agent.h"
#include <stdio.h>
#ifdef CST_HEADLESS_ONLY
int cst_tui_run(cst_agent_t *agent) {
(void)agent;
fprintf(stderr,
"TUI was not built into this binary (CST_BUILD_TUI=OFF).\n"
"Use --headless --prompt '...'\n");
return 1;
}
#endif
+156
View File
@@ -0,0 +1,156 @@
#include "ctl.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
int n_open;
int n_arg;
int n_ready;
int n_text;
int n_error;
char last_name[CTL_MAX_NAME];
char last_id[CTL_MAX_ID];
char last_path[512];
size_t text_bytes;
} sink_t;
static void on_ev(const ctl_event_t *ev, void *ud) {
sink_t *s = ud;
switch (ev->type) {
case CTL_EV_CALL_OPEN:
s->n_open++;
snprintf(s->last_id, sizeof(s->last_id), "%s", ev->call->id);
snprintf(s->last_name, sizeof(s->last_name), "%s", ev->call->name);
break;
case CTL_EV_ARG:
s->n_arg++;
if (ev->call->nargs > 0) {
const ctl_arg_t *a = &ev->call->args[ev->call->nargs - 1];
if (strcmp(a->key, "path") == 0 && a->value)
snprintf(s->last_path, sizeof(s->last_path), "%s", a->value);
}
break;
case CTL_EV_CALL_READY:
s->n_ready++;
break;
case CTL_EV_TEXT:
s->n_text++;
s->text_bytes += ev->text_len;
break;
case CTL_EV_ERROR:
s->n_error++;
break;
default:
break;
}
}
static int expect(int cond, const char *msg) {
if (!cond) {
fprintf(stderr, "FAIL: %s\n", msg);
return 0;
}
return 1;
}
static int test_chunked_confirm(void) {
sink_t s = {0};
ctl_parser_t *p = ctl_parser_create(on_ev, &s);
const char *msg =
"I'll read the file.\n"
"call t1 read_file\n"
"arg path 10\n"
"README.mdx"
"end\n";
/* feed 1 byte at a time */
for (size_t i = 0; msg[i]; i++) {
if (ctl_parser_feed(p, &msg[i], 1) != CTL_OK) {
fprintf(stderr, "feed failed at %zu\n", i);
ctl_parser_destroy(p);
return 0;
}
}
int ok = 1;
ok &= expect(ctl_parser_finish(p) == CTL_OK, "finish ok");
ok &= expect(s.n_ready == 1, "one ready");
ok &= expect(s.n_open == 1, "one open");
ok &= expect(s.n_arg == 1, "one arg");
ok &= expect(strcmp(s.last_name, "read_file") == 0, "name");
ok &= expect(strcmp(s.last_path, "README.mdx") == 0, "path");
ok &= expect(s.n_error == 0, "no error");
ok &= expect(ctl_parser_pending(p) == 0, "pending 0");
/* pending peak should be bounded by line/arg */
ok &= expect(ctl_parser_pending_peak(p) <= CTL_MAX_LINE, "peak bounded");
ctl_parser_destroy(p);
return ok;
}
static int test_truncate_no_execute(void) {
sink_t s = {0};
ctl_parser_t *p = ctl_parser_create(on_ev, &s);
const char *msg =
"call t1 read_file\n"
"arg path 20\n"
"partial"; /* incomplete: need 20 bytes, then end */
ctl_parser_feed(p, msg, strlen(msg));
int ok = 1;
ok &= expect(s.n_ready == 0, "no ready before EOF");
ok &= expect(s.n_open == 1, "open confirmed");
ok &= expect(ctl_parser_finish(p) == CTL_ERR_CONTRACT, "finish error");
ok &= expect(s.n_error >= 1, "error emitted");
ok &= expect(s.n_ready == 0, "still no ready");
ctl_parser_destroy(p);
return ok;
}
static int test_monotonic_no_retract(void) {
/* Once CALL_READY fires, further feed of garbage should error, not retract */
sink_t s = {0};
ctl_parser_t *p = ctl_parser_create(on_ev, &s);
const char *okmsg =
"call a1 run_shell\n"
"arg cmd 5\n"
"ls -l"
"end\n";
ctl_parser_feed(p, okmsg, strlen(okmsg));
int ready = s.n_ready;
int ok = expect(ready == 1, "ready once");
/* text after is fine */
ctl_parser_feed(p, "done.\n", 6);
ok &= expect(s.n_ready == 1, "ready unchanged");
ctl_parser_finish(p);
ctl_parser_destroy(p);
return ok;
}
static int test_text_before_call(void) {
sink_t s = {0};
ctl_parser_t *p = ctl_parser_create(on_ev, &s);
const char *msg = "Hello\nWorld\n";
ctl_parser_feed(p, msg, strlen(msg));
ctl_parser_finish(p);
int ok = expect(s.n_text == 2, "two text lines");
ok &= expect(s.n_ready == 0, "no calls");
ctl_parser_destroy(p);
return ok;
}
int main(void) {
int nfail = 0;
if (!test_chunked_confirm())
nfail++;
if (!test_truncate_no_execute())
nfail++;
if (!test_monotonic_no_retract())
nfail++;
if (!test_text_before_call())
nfail++;
if (nfail) {
fprintf(stderr, "%d test(s) failed\n", nfail);
return 1;
}
printf("test_ctl_frontier: OK\n");
return 0;
}
+55
View File
@@ -0,0 +1,55 @@
#include "cst_agent.h"
#include <stdio.h>
#include <string.h>
static int expect(int cond, const char *msg) {
if (!cond) {
fprintf(stderr, "FAIL: %s\n", msg);
return 0;
}
return 1;
}
static int test_complete_json(void) {
const char *j =
"Here is a call {\"name\":\"read_file\",\"arguments\":{\"path\":\"a.c\"}} "
"done";
cst_json_tool_t t;
int ok = cst_json_try_parse_tool(j, strlen(j), &t);
int pass = expect(ok == 1, "parse ok");
pass &= expect(t.ok == 1, "ok flag");
pass &= expect(strcmp(t.name, "read_file") == 0, "name");
pass &= expect(strstr(t.arguments_json, "path") != NULL, "args");
return pass;
}
static int test_truncated_json(void) {
const char *j = "{\"name\":\"read_file\",\"arguments\":{\"path\":\"a";
cst_json_tool_t t;
int ok = cst_json_try_parse_tool(j, strlen(j), &t);
return expect(ok == 0, "truncated must fail");
}
static int test_unclosed_brace(void) {
const char *j = "{\"name\":\"run_shell\",\"arguments\":{\"cmd\":\"ls\"";
cst_json_tool_t t;
int ok = cst_json_try_parse_tool(j, strlen(j), &t);
return expect(ok == 0, "unclosed brace fails");
}
int main(void) {
int nfail = 0;
if (!test_complete_json())
nfail++;
if (!test_truncated_json())
nfail++;
if (!test_unclosed_brace())
nfail++;
if (nfail) {
fprintf(stderr, "%d failed\n", nfail);
return 1;
}
printf("test_json_baseline: OK\n");
return 0;
}
+65
View File
@@ -0,0 +1,65 @@
#include "mm_tool.h"
#include <stdio.h>
#include <string.h>
typedef struct {
int n_ready;
int n_text;
char name[64];
char path[256];
} sink_t;
static void on_ev(const ctl_event_t *ev, void *ud) {
sink_t *s = ud;
if (ev->type == CTL_EV_TEXT)
s->n_text++;
if (ev->type == CTL_EV_CALL_READY && ev->call) {
s->n_ready++;
snprintf(s->name, sizeof(s->name), "%s", ev->call->name);
if (ev->call->nargs > 0 && ev->call->args[0].value)
snprintf(s->path, sizeof(s->path), "%s", ev->call->args[0].value);
}
}
static int expect(int c, const char *m) {
if (!c) {
fprintf(stderr, "FAIL: %s\n", m);
return 0;
}
return 1;
}
int main(void) {
sink_t s = {0};
mm_scan_t *mm = mm_scan_create(on_ev, &s);
const char *msg =
"我来读一下。\n"
"<minimax:tool_call>\n"
"invoke read_file\n"
"<arg name=\"path\">./README.md</arg>\n"
"</minimax:tool_call>\n"
"读完了。\n";
/* byte-at-a-time */
for (size_t i = 0; msg[i]; i++)
mm_scan_feed(mm, &msg[i], 1);
mm_scan_finish(mm);
int ok = 1;
ok &= expect(s.n_ready == 1, "one ready");
ok &= expect(strcmp(s.name, "read_file") == 0, "name");
ok &= expect(strcmp(s.path, "./README.md") == 0, "path");
ok &= expect(s.n_text >= 1, "text emitted");
/* truncated: no execute */
sink_t s2 = {0};
mm_scan_t *mm2 = mm_scan_create(on_ev, &s2);
const char *trunc = "<minimax:tool_call>\ninvoke read_file\n";
mm_scan_feed(mm2, trunc, strlen(trunc));
mm_scan_finish(mm2);
ok &= expect(s2.n_ready == 0, "trunc no ready");
mm_scan_destroy(mm);
mm_scan_destroy(mm2);
if (!ok)
return 1;
printf("test_mm_tool: OK\n");
return 0;
}
+102
View File
@@ -0,0 +1,102 @@
#include "ctl.h"
#include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/*
* Starvation replay: same logical tool call, cut at several prefixes.
* CTL+CST should never execute on incomplete prefixes; JSON-buffer fails to
* parse (cannot execute). When the full stream is present, both succeed.
*/
typedef struct {
int n_ready;
size_t peak;
} ctl_stats_t;
static void on_ev(const ctl_event_t *ev, void *ud) {
ctl_stats_t *s = ud;
if (ev->type == CTL_EV_CALL_READY)
s->n_ready++;
}
static int ctl_ready_on_prefix(const char *full, size_t ncut, size_t *peak) {
ctl_stats_t st = {0};
ctl_parser_t *p = ctl_parser_create(on_ev, &st);
ctl_parser_feed(p, full, ncut);
/* do not finish — simulate stream stall / truncation mid-turn */
*peak = ctl_parser_pending_peak(p);
int ready = st.n_ready;
ctl_parser_destroy(p);
return ready;
}
int main(void) {
const char *ctl =
"call x1 read_file\n"
"arg path 5\n"
"a.txt"
"end\n";
const char *json =
"{\"name\":\"read_file\",\"arguments\":{\"path\":\"a.txt\"}}";
size_t ctl_len = strlen(ctl);
size_t json_len = strlen(json);
int ctl_premature = 0, json_ok_full = 0, ctl_ok_full = 0;
int json_fail_trunc = 0, ctl_safe_trunc = 0;
/* full streams */
{
size_t peak = 0;
ctl_stats_t st = {0};
ctl_parser_t *p = ctl_parser_create(on_ev, &st);
ctl_parser_feed(p, ctl, ctl_len);
ctl_parser_finish(p);
peak = ctl_parser_pending_peak(p);
ctl_ok_full = (st.n_ready == 1);
printf("CTL full: ready=%d peak=%zu\n", st.n_ready, peak);
ctl_parser_destroy(p);
}
{
cst_json_tool_t t;
json_ok_full = cst_json_try_parse_tool(json, json_len, &t);
printf("JSON full: ok=%d name=%s\n", json_ok_full, t.name);
}
/* truncate at 50% and 80% */
size_t cuts[] = {ctl_len / 2, (ctl_len * 4) / 5, ctl_len - 2};
for (size_t i = 0; i < sizeof(cuts) / sizeof(cuts[0]); i++) {
size_t peak = 0;
int ready = ctl_ready_on_prefix(ctl, cuts[i], &peak);
printf("CTL cut %zu/%zu: ready=%d peak=%zu\n", cuts[i], ctl_len, ready,
peak);
if (ready)
ctl_premature++;
else
ctl_safe_trunc++;
}
size_t jcuts[] = {json_len / 2, (json_len * 4) / 5, json_len - 2};
for (size_t i = 0; i < sizeof(jcuts) / sizeof(jcuts[0]); i++) {
cst_json_tool_t t;
int ok = cst_json_try_parse_tool(json, jcuts[i], &t);
printf("JSON cut %zu/%zu: ok=%d\n", jcuts[i], json_len, ok);
if (!ok)
json_fail_trunc++;
}
printf("\n=== summary ===\n");
printf("CTL full success: %s\n", ctl_ok_full ? "yes" : "no");
printf("JSON full success: %s\n", json_ok_full ? "yes" : "no");
printf("CTL premature executes on trunc: %d (want 0)\n", ctl_premature);
printf("CTL safe truncations: %d\n", ctl_safe_trunc);
printf("JSON trunc failures: %d\n", json_fail_trunc);
if (!ctl_ok_full || !json_ok_full || ctl_premature != 0)
return 1;
printf("replay_starvation: OK\n");
return 0;
}
+2
View File
@@ -0,0 +1,2 @@
BusyBox is GPLv2. Source: https://github.com/rmyorston/busybox-w32
Upstream BusyBox: https://github.com/mirror/busybox
+26
View File
@@ -0,0 +1,26 @@
# BusyBox for CST-Agent
## Windows (`busybox.exe`)
Built from **busybox-w32**:
https://github.com/rmyorston/busybox-w32.git
```bash
./scripts/build_third_party_mingw.sh busybox-w32
# → third_party/busybox/busybox.exe
```
Ship next to `cst-agent.exe`. License: GPLv2 (`LICENSE.busybox`).
## Linux / Termux (optional side-by-side `busybox`)
Upstream mirror:
https://github.com/mirror/busybox.git
```bash
./scripts/build_third_party_mingw.sh busybox-unix
# → third_party/busybox/busybox
```
On Unix, `run_shell` prefers `CST_SHELL`, then `<exe_dir>/busybox`, then
`$PREFIX/bin/sh` (Termux), then `/bin/sh`.
+16
View File
@@ -0,0 +1,16 @@
CC ?= gcc
CFLAGS ?= -std=c11 -Wall -Wextra -O2 -Iinclude -Isrc
SRCS = src/fsm.c src/table_stats.c src/line_index.c src/block_scan.c \
src/ansi_style.c src/render.c src/render_ansi.c src/render_json.c \
src/sashidown.c src/compat_cmark.c src/stream.c
OBJS = $(SRCS:.c=.o)
LIB = libsashidown.a
.PHONY: all clean
all: $(LIB)
$(LIB): $(OBJS)
$(AR) rcs $@ $(OBJS)
src/%.o: src/%.c
$(CC) $(CFLAGS) -c -o $@ $<
clean:
rm -f $(OBJS) $(LIB)
+64
View File
@@ -0,0 +1,64 @@
#ifndef SASHIDOWN_H
#define SASHIDOWN_H
#include <stddef.h>
#define SASHIDOWN_OUTPUT_DEFAULT 65536
typedef struct {
const char *input;
size_t input_len;
char *output;
size_t output_cap;
size_t output_len;
char *write_ptr;
char *write_end;
void *line_ix;
int overflow; /* set when output buffer is exhausted */
} sashidown_ctx_t;
/* Parse markdown to HTML. Returns 0 on success, -1 on error
* (null args, or output buffer too small — never silently truncates). */
int sashidown_parse(const char *input, size_t len,
char *output, size_t cap, size_t *out_len);
typedef enum {
SASHIDOWN_FMT_HTML = 0,
SASHIDOWN_FMT_ANSI = 1, /* built-in colorful terminal theme */
SASHIDOWN_FMT_JSON = 2,
SASHIDOWN_FMT_ANSI_PLAIN = 3, /* monochrome / SGR attributes only */
} sashidown_format_t;
/* Single-pass convert. SASHIDOWN_FMT_HTML delegates to sashidown_parse.
* Returns 0 on success, -1 on error (null args, bad format, or buffer too small). */
int sashidown_convert(const char *input, size_t len,
char *output, size_t cap, size_t *out_len,
sashidown_format_t format);
/* Allocating wrapper (thread-safe). Caller must free via sashidown_free. */
char *cmark_markdown_to_html(const char *text, int len);
void sashidown_free(void *ptr);
/* --- Streaming API (AI conversation / incremental input) --- */
typedef struct sashidown_stream sashidown_stream_t;
/* Called for each HTML chunk; return non-zero to abort feeding. */
typedef int (*sashidown_stream_cb)(const char *html, size_t len, void *user);
typedef struct {
sashidown_stream_cb on_html;
void *user;
size_t output_buf_size; /* default 8192 if 0; grows on overflow */
} sashidown_stream_opts_t;
sashidown_stream_t *sashidown_stream_create(const sashidown_stream_opts_t *opts);
int sashidown_stream_feed(sashidown_stream_t *s, const char *chunk, size_t len);
int sashidown_stream_finish(sashidown_stream_t *s);
void sashidown_stream_destroy(sashidown_stream_t *s);
/* Bytes held past the confirmation frontier (input_len - render_pos).
* Unconfirmed suffix under uncertain future input; O(1) query. */
size_t sashidown_stream_pending(const sashidown_stream_t *s);
#endif /* SASHIDOWN_H */
+104
View File
@@ -0,0 +1,104 @@
#include "ansi_style.h"
#include <stddef.h>
/* Default colorful theme — 16-color SGR, widely supported. */
static const ansi_theme_t THEME_COLOR = {
.reset = "\033[0m",
.heading = {
NULL,
"\033[1;96m", /* h1 bright cyan */
"\033[1;94m", /* h2 bright blue */
"\033[1;92m", /* h3 bright green */
"\033[1;95m", /* h4 bright magenta */
"\033[1;93m", /* h5 bright yellow */
"\033[1;91m", /* h6 bright red */
},
.heading_end = "\033[0m",
.strong_on = "\033[1m",
.strong_off = "\033[22m",
.emph_on = "\033[3;33m", /* italic yellow */
.emph_off = "\033[23;39m",
.strike_on = "\033[9m",
.strike_off = "\033[29m",
.code_on = "\033[1;36m", /* bright cyan */
.code_off = "\033[22;39m",
.fence_on = "\033[32m", /* green */
.fence_off = "\033[0m",
.link_on = "\033[4;94m", /* underline bright blue */
.link_off = "\033[24;39m",
.url_on = "\033[2;36m", /* dim cyan */
.url_off = "\033[22;39m",
.image_on = "\033[95m",
.image_off = "\033[39m",
.bullet = "\033[93m• \033[0m",
.bullet_end = "",
.ol_on = "\033[93m",
.ol_off = "\033[0m",
.bq_prefix = "\033[2;35m│ \033[0m",
.th_on = "\033[1;96m",
.th_off = "\033[0m",
.sep = "\033[2m │ \033[0m",
.latex_on = "\033[95m",
.latex_off = "\033[39m",
};
/* Monochrome / attribute-only (previous behavior). */
static const ansi_theme_t THEME_PLAIN = {
.reset = "\033[0m",
.heading = {
NULL,
"\033[1m", "\033[1m", "\033[1m",
"\033[1m", "\033[1m", "\033[1m",
},
.heading_end = "\033[0m",
.strong_on = "\033[1m",
.strong_off = "\033[22m",
.emph_on = "\033[3m",
.emph_off = "\033[23m",
.strike_on = "\033[9m",
.strike_off = "\033[29m",
.code_on = "\033[7m",
.code_off = "\033[27m",
.fence_on = "\033[2m",
.fence_off = "\033[0m",
.link_on = "",
.link_off = "",
.url_on = "",
.url_off = "",
.image_on = "",
.image_off = "",
.bullet = "• ",
.bullet_end = "",
.ol_on = "",
.ol_off = "",
.bq_prefix = "\033[2m| \033[0m",
.th_on = "\033[1m",
.th_off = "\033[22m",
.sep = " | ",
.latex_on = "",
.latex_off = "",
};
const ansi_theme_t *ansi_theme_color(void)
{
return &THEME_COLOR;
}
const ansi_theme_t *ansi_theme_plain(void)
{
return &THEME_PLAIN;
}
+48
View File
@@ -0,0 +1,48 @@
#ifndef SASHIDOWN_ANSI_STYLE_H
#define SASHIDOWN_ANSI_STYLE_H
/* Built-in ANSI role styles (SGR). Strings are compile-time literals. */
typedef struct {
const char *reset;
const char *heading[7]; /* [1..6] used; [0] unused */
const char *heading_end;
const char *strong_on;
const char *strong_off;
const char *emph_on;
const char *emph_off;
const char *strike_on;
const char *strike_off;
const char *code_on;
const char *code_off;
const char *fence_on;
const char *fence_off;
const char *link_on;
const char *link_off;
const char *url_on;
const char *url_off;
const char *image_on;
const char *image_off;
const char *bullet;
const char *bullet_end;
const char *ol_on;
const char *ol_off;
const char *bq_prefix;
const char *th_on;
const char *th_off;
const char *sep; /* table column separator */
const char *latex_on;
const char *latex_off;
} ansi_theme_t;
const ansi_theme_t *ansi_theme_color(void);
const ansi_theme_t *ansi_theme_plain(void);
#endif
+337
View File
@@ -0,0 +1,337 @@
#include "block_scan.h"
#include <string.h>
int block_count_heading(const char *input, size_t len, size_t pos, size_t *content_start)
{
size_t i = pos;
int level = 0;
while (i < len && input[i] == '#') {
level++;
i++;
}
if (level == 0 || level > 6) return 0;
if (i < len && input[i] != ' ' && input[i] != '\n') return 0;
while (i < len && input[i] == ' ') i++;
*content_start = i;
return level;
}
int block_is_fence(const char *input, size_t len, size_t pos,
int *fence_len, char *fence_char)
{
size_t i = pos;
while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
if (i + 2 >= len) return 0;
char ch = input[i];
if (ch != '`' && ch != '~') return 0;
if (input[i + 1] != ch || input[i + 2] != ch) return 0;
int fl = 3;
i += 3;
while (i < len && input[i] == ch) { fl++; i++; }
*fence_len = fl;
if (fence_char)
*fence_char = ch;
return 1;
}
void block_fence_info_range(const char *input, size_t len, size_t pos,
int fence_len, char fence_char,
size_t *info_start, size_t *info_end)
{
size_t i = pos;
while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
i += (size_t)fence_len;
(void)fence_char;
while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
*info_start = i;
size_t end = i;
while (end < len && input[end] != '\n' && input[end] != '\r')
end++;
while (end > *info_start &&
(input[end - 1] == ' ' || input[end - 1] == '\t'))
end--;
*info_end = end;
}
int block_is_thematic_break(const char *input, size_t pos, size_t le)
{
size_t i = pos;
while (i < le && (input[i] == ' ' || input[i] == '\t')) i++;
if (i >= le) return 0;
char marker = input[i];
if (marker != '*' && marker != '-' && marker != '_') return 0;
int count = 0;
for (; i < le; i++) {
char c = input[i];
if (c == marker) {
count++;
} else if (c == ' ' || c == '\t') {
continue;
} else if (c == '\r') {
continue;
} else {
return 0;
}
}
return count >= 3;
}
int block_is_setext_underline(const char *input, size_t pos, size_t le, int *level)
{
size_t i = pos;
while (i < le && (input[i] == ' ' || input[i] == '\t')) i++;
if (i >= le) return 0;
char marker = input[i];
if (marker != '=' && marker != '-') return 0;
size_t start = i;
while (i < le && input[i] == marker) i++;
if (i == start) return 0;
while (i < le && (input[i] == ' ' || input[i] == '\t' || input[i] == '\r'))
i++;
if (i < le) return 0;
/* Thematic-break-looking '---' is still a valid setext underline when
* paired with a preceding text line (caller decides context). */
if (level)
*level = (marker == '=') ? 1 : 2;
return 1;
}
int block_is_blockquote(const char *input, size_t len, size_t pos, size_t *content_start)
{
size_t i = pos;
while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
if (i >= len || input[i] != '>') return 0;
i++;
if (i < len && input[i] == ' ') i++;
*content_start = i;
return 1;
}
int block_is_ul(const char *input, size_t len, size_t pos, size_t *content_start)
{
size_t i = pos;
while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
if (i >= len) return 0;
if (input[i] != '-' && input[i] != '*' && input[i] != '+') return 0;
if (i + 1 < len && input[i + 1] != ' ') return 0;
i += 2;
*content_start = i;
return 1;
}
int block_parse_task_marker(const char *input, size_t len, size_t content_start,
int *task, size_t *text_start)
{
size_t i = content_start;
if (i + 3 > len) return 0;
if (input[i] != '[') return 0;
char mark = input[i + 1];
if (input[i + 2] != ']') return 0;
if (mark != ' ' && mark != 'x' && mark != 'X') return 0;
i += 3;
if (i < len && input[i] == ' ') i++;
*task = (mark == ' ') ? 1 : 2;
*text_start = i;
return 1;
}
int block_is_ol(const char *input, size_t len, size_t pos, size_t *content_start, int *num)
{
size_t i = pos;
while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
size_t num_start = i;
{ unsigned char c = (unsigned char)input[i];
if (i >= len || c < '0' || c > '9') return 0; }
while (i < len) {
unsigned char c = (unsigned char)input[i];
if (c < '0' || c > '9') break;
i++;
}
if (i >= len || input[i] != '.') return 0;
if (i + 1 < len && input[i + 1] != ' ') return 0;
*num = 0;
for (size_t j = num_start; j < i; j++)
*num = *num * 10 + (input[j] - '0');
i += 2;
*content_start = i;
return 1;
}
int block_line_starts_table(const char *input, size_t len, size_t pos)
{
size_t i = pos;
while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
if (i >= len) return 0;
/* GFM: leading '|' OR a pipe somewhere on the line with a separator below.
* Cheap reject: must contain '|' before newline. */
const char *nl = memchr(input + i, '\n', len - i);
size_t line_len = nl ? (size_t)(nl - (input + i)) : (len - i);
return memchr(input + i, '|', line_len) != NULL;
}
int block_is_latex_line(sashidown_ctx_t *ctx, const char *input, size_t len,
size_t pos, size_t *content_start, size_t *content_end)
{
size_t i = pos;
while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
if (i + 1 >= len || input[i] != '$' || input[i + 1] != '$') return 0;
i += 2;
*content_start = i;
size_t le = block_line_end(ctx, input, len, pos);
if (i + 1 < le && input[le - 2] == '$' && input[le - 1] == '$') {
*content_end = le - 2;
return 1;
}
return 0;
}
int block_try_setext(sashidown_ctx_t *ctx, const char *input, size_t len,
size_t pos, size_t le, size_t stop, int is_final,
line_info_t *info, size_t *end_pos)
{
if (pos == le || (pos + 1 == le && input[pos] == '\r'))
return 0;
/* Next line starts after the newline at `le` (line_end points at '\n'). */
if (le >= len || input[le] != '\n')
return is_final ? 0 : -1;
size_t next = le + 1;
if (next >= len)
return is_final ? 0 : -1;
if (next >= stop && !is_final)
return -1;
/* Cheap reject before full underline scan. */
size_t u = next;
while (u < len && (input[u] == ' ' || input[u] == '\t'))
u++;
if (u >= len || (input[u] != '=' && input[u] != '-'))
return 0;
size_t nle = block_line_end(ctx, input, len, next);
int level = 0;
if (!block_is_setext_underline(input, next, nle, &level))
return 0;
info->kind = LK_HEADING;
info->heading_level = level;
info->content_start = pos;
while (info->content_start < le &&
(input[info->content_start] == ' ' || input[info->content_start] == '\t'))
info->content_start++;
info->content_end = le;
while (info->content_end > info->content_start &&
(input[info->content_end - 1] == ' ' ||
input[info->content_end - 1] == '\t' ||
input[info->content_end - 1] == '\r'))
info->content_end--;
info->fence_len = 0;
info->fence_char = 0;
info->info_start = 0;
info->info_end = 0;
info->task = 0;
*end_pos = block_skip_line(ctx, input, len, next);
return 1;
}
line_info_t block_classify_line(sashidown_ctx_t *ctx, const char *input, size_t len,
size_t pos, size_t le)
{
line_info_t info;
memset(&info, 0, sizeof(info));
info.kind = LK_PARA;
info.content_start = pos;
info.content_end = le;
if (pos == le || (pos + 1 == le && input[pos] == '\r')) {
info.kind = LK_EMPTY;
return info;
}
size_t i = pos;
while (i < len && (input[i] == ' ' || input[i] == '\t')) i++;
unsigned char first = (i < len) ? (unsigned char)input[i] : 0;
/* Thematic breaks only possible for - / * / _ lines. */
if ((first == '*' || first == '-' || first == '_') &&
block_is_thematic_break(input, pos, le)) {
info.kind = LK_HR;
return info;
}
switch (first) {
case '#':
info.heading_level = block_count_heading(input, len, pos, &info.content_start);
if (info.heading_level > 0) {
info.kind = LK_HEADING;
info.content_end = le;
return info;
}
break;
case '`':
case '~':
if (block_is_fence(input, len, pos, &info.fence_len, &info.fence_char)) {
info.kind = LK_FENCE;
block_fence_info_range(input, len, pos, info.fence_len, info.fence_char,
&info.info_start, &info.info_end);
return info;
}
break;
case '>':
if (block_is_blockquote(input, len, pos, &info.content_start)) {
info.kind = LK_BQ;
return info;
}
break;
case '-': case '*': case '+':
if (block_is_ul(input, len, pos, &info.content_start)) {
info.kind = LK_UL;
size_t text = info.content_start;
int task = 0;
if (block_parse_task_marker(input, le, info.content_start, &task, &text)) {
info.task = task;
info.content_start = text;
}
return info;
}
break;
case '0': case '1': case '2': case '3': case '4':
case '5': case '6': case '7': case '8': case '9':
{
int ol_num = 0;
if (block_is_ol(input, len, pos, &info.content_start, &ol_num)) {
info.kind = LK_OL;
return info;
}
break;
}
case '$':
{
size_t cs, ce;
if (i + 1 < len && input[i] == '$' && input[i + 1] == '$') {
if (i == pos) {
info.kind = LK_LATEX_BLOCK;
return info;
}
if (block_is_latex_line(ctx, input, len, pos, &cs, &ce)) {
info.kind = LK_LATEX_LINE;
info.content_start = cs;
info.content_end = ce;
return info;
}
}
break;
}
default:
break;
}
info.kind = LK_PARA;
return info;
}
+196
View File
@@ -0,0 +1,196 @@
#ifndef SASHIDOWN_BLOCK_SCAN_H
#define SASHIDOWN_BLOCK_SCAN_H
#include "sashidown.h"
#include "render.h"
#include "fsm.h"
#include "line_index.h"
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#ifdef __SSE2__
#include <emmintrin.h>
#elif defined(__ARM_NEON) || defined(__aarch64__)
#include <arm_neon.h>
#endif
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Woverride-init"
static inline size_t block_utf8_seq_len(unsigned char ch)
{
static const unsigned char tbl[256] = {
[0x00 ... 0xFF] = 1,
[0xC0 ... 0xDF] = 2,
[0xE0 ... 0xEF] = 3,
[0xF0 ... 0xF7] = 4,
};
return tbl[ch];
}
#pragma GCC diagnostic pop
static inline size_t block_scan_plain_ascii(const char *restrict input, size_t end, size_t i)
{
#ifdef __SSE2__
const __m128i m_star = _mm_set1_epi8('*');
const __m128i m_us = _mm_set1_epi8('_');
const __m128i m_tick = _mm_set1_epi8('`');
const __m128i m_dol = _mm_set1_epi8('$');
const __m128i m_bang = _mm_set1_epi8('!');
const __m128i m_lb = _mm_set1_epi8('[');
const __m128i m_amp = _mm_set1_epi8('&');
const __m128i m_lt = _mm_set1_epi8('<');
const __m128i m_gt = _mm_set1_epi8('>');
const __m128i m_bs = _mm_set1_epi8('\\');
const __m128i m_tilde = _mm_set1_epi8('~');
while (i + 16 <= end) {
__m128i v = _mm_loadu_si128((const __m128i *)(input + i));
if (_mm_movemask_epi8(v))
break; /* high bit => UTF-8 */
__m128i hit = _mm_cmpeq_epi8(v, m_star);
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_us));
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_tick));
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_dol));
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_bang));
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_lb));
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_amp));
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_lt));
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_gt));
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_bs));
hit = _mm_or_si128(hit, _mm_cmpeq_epi8(v, m_tilde));
int mask = _mm_movemask_epi8(hit);
if (mask)
return i + (unsigned)__builtin_ctz(mask);
i += 16;
}
#elif defined(__ARM_NEON) || defined(__aarch64__)
while (i + 16 <= end) {
uint8x16_t v = vld1q_u8((const uint8_t *)(input + i));
if (vmaxvq_u8(vandq_u8(v, vdupq_n_u8(0x80))))
break;
uint8x16_t hit = vceqq_u8(v, vdupq_n_u8('*'));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('_')));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('`')));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('$')));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('!')));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('[')));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('&')));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('<')));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('>')));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('\\')));
hit = vorrq_u8(hit, vceqq_u8(v, vdupq_n_u8('~')));
if (vmaxvq_u8(hit)) {
uint8_t lanes[16];
vst1q_u8(lanes, hit);
size_t pos = 0;
while (pos < 16 && !lanes[pos]) pos++;
return i + pos;
}
i += 16;
}
#endif
size_t j = i;
while (j + 4 <= end) {
uint32_t v;
memcpy(&v, input + j, 4);
if (v & 0x80808080)
break;
if (CH_SCAN_STOP[(v & 0xFF)] ||
CH_SCAN_STOP[(v >> 8) & 0xFF] ||
CH_SCAN_STOP[(v >> 16) & 0xFF] ||
CH_SCAN_STOP[(v >> 24) & 0xFF])
break;
j += 4;
}
while (j < end) {
unsigned char c = (unsigned char)input[j];
if ((c & 0x80))
break;
if (unlikely(CH_SCAN_STOP[c]))
break;
j++;
}
return j;
}
static inline size_t block_scan_utf8_run(const char *input, size_t end, size_t i)
{
size_t j = i;
while (j < end) {
unsigned char c = (unsigned char)input[j];
if (likely((c & 0x80) == 0))
break;
size_t seq = block_utf8_seq_len(c);
if (j + seq > end)
break;
j += seq;
}
return j;
}
static inline size_t block_scan_until_byte(const char *input, size_t end, size_t i, char delim)
{
if (i >= end)
return i;
const char *hit = memchr(input + i, delim, end - i);
return hit ? (size_t)(hit - input) : end;
}
static inline size_t block_scan_until_2byte(const char *input, size_t end, size_t start,
char c1, char c2)
{
size_t i = start;
while (i + 1 < end) {
const char *hit = memchr(input + i, c1, end - i);
if (!hit) return end;
i = (size_t)(hit - input);
if (i + 1 < end && input[i + 1] == c2)
return i;
i++;
}
return end;
}
static inline size_t block_line_end(sashidown_ctx_t *ctx, const char *input, size_t len, size_t pos)
{
return line_end_ix((line_index_t *)ctx->line_ix, input, len, pos);
}
static inline size_t block_skip_line(sashidown_ctx_t *ctx, const char *input, size_t len, size_t pos)
{
return skip_line_ix((line_index_t *)ctx->line_ix, input, len, pos);
}
static inline void block_line_seek(sashidown_ctx_t *ctx, size_t pos)
{
line_index_seek((line_index_t *)ctx->line_ix, pos);
}
int block_count_heading(const char *input, size_t len, size_t pos, size_t *content_start);
int block_is_fence(const char *input, size_t len, size_t pos,
int *fence_len, char *fence_char);
void block_fence_info_range(const char *input, size_t len, size_t pos,
int fence_len, char fence_char,
size_t *info_start, size_t *info_end);
int block_is_thematic_break(const char *input, size_t pos, size_t le);
int block_is_setext_underline(const char *input, size_t pos, size_t le, int *level);
int block_is_blockquote(const char *input, size_t len, size_t pos, size_t *content_start);
int block_is_ul(const char *input, size_t len, size_t pos, size_t *content_start);
int block_is_ol(const char *input, size_t len, size_t pos, size_t *content_start, int *num);
int block_parse_task_marker(const char *input, size_t len, size_t content_start,
int *task, size_t *text_start);
int block_line_starts_table(const char *input, size_t len, size_t pos);
int block_is_latex_line(sashidown_ctx_t *ctx, const char *input, size_t len,
size_t pos, size_t *content_start, size_t *content_end);
/* Peek next-line setext underline. Does NOT advance line_index on failure.
* `le` must be block_line_end(pos). Caller should only invoke for LK_PARA lines.
* Returns 1 = heading, 0 = no, -1 = defer (need next line). */
int block_try_setext(sashidown_ctx_t *ctx, const char *input, size_t len,
size_t pos, size_t le, size_t stop, int is_final,
line_info_t *info, size_t *end_pos);
line_info_t block_classify_line(sashidown_ctx_t *ctx, const char *input, size_t len,
size_t pos, size_t le);
#endif /* SASHIDOWN_BLOCK_SCAN_H */
+44
View File
@@ -0,0 +1,44 @@
#include "sashidown.h"
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
char *cmark_markdown_to_html(const char *text, int len)
{
if (!text)
return NULL;
if (len < 0)
len = (int)strlen(text);
size_t in_len = (size_t)len;
/* HTML typically expands; grow until parse fits (thread-safe, no static buf). */
size_t cap = in_len * 4 + 256;
if (cap < 1024)
cap = 1024;
for (;;) {
char *buf = (char *)malloc(cap);
if (!buf)
return NULL;
size_t out_len = 0;
if (sashidown_parse(text, in_len, buf, cap, &out_len) == 0) {
char *fit = (char *)realloc(buf, out_len + 1);
return fit ? fit : buf;
}
free(buf);
if (cap > SIZE_MAX / 2)
return NULL;
size_t next = cap * 2;
/* Bail if expansion is absurd relative to input. */
if (next > in_len * 64 + 4096)
return NULL;
cap = next;
}
}
void sashidown_free(void *ptr)
{
free(ptr);
}
+110
View File
@@ -0,0 +1,110 @@
#include "fsm.h"
#include <string.h>
const uint8_t CH_HTML_ESCAPE[256] = {
['&'] = 1, ['<'] = 1, ['>'] = 1, ['"'] = 1,
};
const uint8_t CH_JSON_ESCAPE[256] = {
['"'] = 1, ['\\'] = 1,
[0x00] = 1, [0x01] = 1, [0x02] = 1, [0x03] = 1,
[0x04] = 1, [0x05] = 1, [0x06] = 1, [0x07] = 1,
[0x08] = 1, [0x09] = 1, [0x0A] = 1, [0x0B] = 1,
[0x0C] = 1, [0x0D] = 1, [0x0E] = 1, [0x0F] = 1,
[0x10] = 1, [0x11] = 1, [0x12] = 1, [0x13] = 1,
[0x14] = 1, [0x15] = 1, [0x16] = 1, [0x17] = 1,
[0x18] = 1, [0x19] = 1, [0x1A] = 1, [0x1B] = 1,
[0x1C] = 1, [0x1D] = 1, [0x1E] = 1, [0x1F] = 1,
};
/* Inline special + HTML escape; combine with (c & 0x80) in the scan loop. */
const uint8_t CH_SCAN_STOP[256] = {
['*'] = 1, ['_'] = 1, ['`'] = 1, ['$'] = 1, ['!'] = 1, ['['] = 1,
['&'] = 1, ['<'] = 1, ['>'] = 1, ['\\'] = 1, ['~'] = 1,
};
void fsm_write_begin(sashidown_ctx_t *ctx)
{
ctx->overflow = 0;
ctx->write_ptr = ctx->output + ctx->output_len;
ctx->write_end = ctx->output + ctx->output_cap - 1;
}
void fsm_write_end(sashidown_ctx_t *ctx)
{
if (ctx->write_ptr)
ctx->output_len = (size_t)(ctx->write_ptr - ctx->output);
ctx->write_ptr = NULL;
ctx->write_end = NULL;
}
int fsm_append(sashidown_ctx_t *ctx, char c)
{
if (unlikely(ctx->overflow))
return -1;
if (ctx->write_ptr) {
if (unlikely(ctx->write_ptr >= ctx->write_end)) {
ctx->overflow = 1;
return -1;
}
*ctx->write_ptr++ = c;
return 0;
}
if (unlikely(ctx->output_len + 1 >= ctx->output_cap)) {
ctx->overflow = 1;
return -1;
}
ctx->output[ctx->output_len++] = c;
return 0;
}
int fsm_append_len(sashidown_ctx_t *restrict ctx, const char *restrict s, size_t n)
{
if (n == 0)
return 0;
if (unlikely(ctx->overflow))
return -1;
if (ctx->write_ptr) {
if (unlikely(ctx->write_ptr + n > ctx->write_end)) {
ctx->overflow = 1;
return -1;
}
memcpy(ctx->write_ptr, s, n);
ctx->write_ptr += n;
return 0;
}
if (unlikely(ctx->output_len + n >= ctx->output_cap)) {
ctx->overflow = 1;
return -1;
}
memcpy(ctx->output + ctx->output_len, s, n);
ctx->output_len += n;
return 0;
}
int fsm_append_str(sashidown_ctx_t *ctx, const char *s)
{
return fsm_append_len(ctx, s, strlen(s));
}
void fsm_append_fast(sashidown_ctx_t *ctx, char c)
{
if (unlikely(ctx->overflow)) return;
if (unlikely(ctx->write_ptr >= ctx->write_end)) {
ctx->overflow = 1;
return;
}
*ctx->write_ptr++ = c;
}
void fsm_append_len_fast(sashidown_ctx_t *restrict ctx, const char *restrict s, size_t n)
{
if (n == 0) return;
if (unlikely(ctx->overflow)) return;
if (unlikely(ctx->write_ptr + n > ctx->write_end)) {
ctx->overflow = 1;
return;
}
memcpy(ctx->write_ptr, s, n);
ctx->write_ptr += n;
}
+31
View File
@@ -0,0 +1,31 @@
#ifndef MD_FSM_H
#define MD_FSM_H
#include <stdint.h>
#include <stddef.h>
#include "sashidown.h"
#ifndef likely
#define likely(x) __builtin_expect(!!(x), 1)
#endif
#ifndef unlikely
#define unlikely(x) __builtin_expect(!!(x), 0)
#endif
extern const uint8_t CH_HTML_ESCAPE[256];
extern const uint8_t CH_SCAN_STOP[256];
extern const uint8_t CH_JSON_ESCAPE[256];
int fsm_append(sashidown_ctx_t *ctx, char c);
int fsm_append_str(sashidown_ctx_t *ctx, const char *s);
int fsm_append_len(sashidown_ctx_t *restrict ctx, const char *restrict s, size_t n);
void fsm_append_fast(sashidown_ctx_t *ctx, char c);
void fsm_append_len_fast(sashidown_ctx_t *restrict ctx, const char *restrict s, size_t n);
void fsm_write_begin(sashidown_ctx_t *ctx);
void fsm_write_end(sashidown_ctx_t *ctx);
#define fsm_append_lit(ctx, s) fsm_append_len((ctx), (s), sizeof(s) - 1)
#endif /* MD_FSM_H */
+184
View File
@@ -0,0 +1,184 @@
#include "line_index.h"
#include <stdlib.h>
#include <string.h>
#define LINE_IX_DYNAMIC_INIT 256
static size_t scan_line_end(const char *input, size_t len, size_t pos)
{
const char *hit = memchr(input + pos, '\n', len - pos);
return hit ? (size_t)(hit - input) : len;
}
static size_t scan_skip_line(const char *input, size_t len, size_t pos)
{
pos = scan_line_end(input, len, pos);
if (pos < len)
pos++;
return pos;
}
static int line_index_ensure(line_index_t *ix, uint32_t need_slots)
{
if (need_slots <= ix->cap)
return 0;
if (!ix->owned) {
ix->valid = 0;
return -1;
}
uint32_t cap = ix->cap ? ix->cap : LINE_IX_DYNAMIC_INIT;
while (cap < need_slots)
cap *= 2;
uint32_t *p = (uint32_t *)realloc(ix->off, (size_t)cap * sizeof(uint32_t));
if (!p) {
ix->valid = 0;
return -1;
}
ix->off = p;
ix->cap = cap;
return 0;
}
int line_index_init_dynamic(line_index_t *ix, uint32_t initial_cap)
{
if (!ix)
return -1;
if (initial_cap < 2)
initial_cap = LINE_IX_DYNAMIC_INIT;
uint32_t *off = (uint32_t *)malloc((size_t)initial_cap * sizeof(uint32_t));
if (!off)
return -1;
ix->off = off;
ix->cap = initial_cap;
ix->n = 1;
ix->off[0] = 0;
ix->off[1] = 0;
ix->cur = 0;
ix->valid = 1;
ix->owned = 1;
return 0;
}
void line_index_free(line_index_t *ix)
{
if (!ix)
return;
if (ix->owned) {
free(ix->off);
ix->off = NULL;
ix->owned = 0;
ix->cap = 0;
}
}
void line_index_build(const char *input, size_t len, line_index_t *ix)
{
ix->n = 1;
ix->off[0] = 0;
ix->cur = 0;
ix->valid = 1;
const char *start = input;
const char *end = input + len;
const char *hit;
while ((hit = memchr(start, '\n', (size_t)(end - start))) != NULL) {
if (ix->n + 1 >= ix->cap) {
if (line_index_ensure(ix, ix->n + 2) != 0)
return;
}
ix->off[ix->n++] = (uint32_t)(hit - input) + 1;
start = hit + 1;
}
if (ix->n >= ix->cap) {
if (line_index_ensure(ix, ix->n + 1) != 0)
return;
}
ix->off[ix->n] = (uint32_t)len;
}
size_t line_index_extend(line_index_t *ix, const char *input,
size_t old_len, size_t new_len)
{
if (!ix || !ix->valid || new_len <= old_len)
return 0;
size_t last_nl = 0;
const char *start = input + old_len;
const char *end = input + new_len;
const char *hit;
while ((hit = memchr(start, '\n', (size_t)(end - start))) != NULL) {
if (ix->n + 1 >= ix->cap) {
if (line_index_ensure(ix, ix->n + 2) != 0)
return last_nl;
}
last_nl = (size_t)(hit - input) + 1;
ix->off[ix->n++] = (uint32_t)last_nl;
start = hit + 1;
}
if (ix->n >= ix->cap) {
if (line_index_ensure(ix, ix->n + 1) != 0)
return last_nl;
}
ix->off[ix->n] = (uint32_t)new_len;
return last_nl;
}
static uint32_t line_no(const line_index_t *ix, size_t pos)
{
uint32_t lo = 0;
uint32_t hi = ix->n;
while (lo + 1 < hi) {
uint32_t mid = (lo + hi) / 2;
if (ix->off[mid] <= pos)
lo = mid;
else
hi = mid;
}
return lo;
}
void line_index_seek(line_index_t *ix, size_t pos)
{
if (!ix || !ix->valid)
return;
ix->cur = line_no(ix, pos);
}
static uint32_t resolve_line(line_index_t *ix, size_t pos)
{
/* Sequential hot path: render walks lines in order. */
if (ix->cur < ix->n && ix->off[ix->cur] == (uint32_t)pos)
return ix->cur;
if (ix->cur + 1 < ix->n && ix->off[ix->cur + 1] == (uint32_t)pos) {
ix->cur++;
return ix->cur;
}
ix->cur = line_no(ix, pos);
return ix->cur;
}
size_t line_end_ix(line_index_t *ix, const char *input, size_t len, size_t pos)
{
if (!ix || !ix->valid)
return scan_line_end(input, len, pos);
uint32_t k = resolve_line(ix, pos);
size_t next = (size_t)ix->off[k + 1];
if (next > 0 && next <= len && input[next - 1] == '\n')
return next - 1;
return next <= len ? next : len;
}
size_t skip_line_ix(line_index_t *ix, const char *input, size_t len, size_t pos)
{
if (!ix || !ix->valid)
return scan_skip_line(input, len, pos);
uint32_t k = resolve_line(ix, pos);
size_t next = (size_t)ix->off[k + 1];
if (next > len)
return len;
/* Advance cursor to the next line start (may equal n at EOF). */
ix->cur = k + 1;
return next;
}
+40
View File
@@ -0,0 +1,40 @@
#ifndef MD_LINE_INDEX_H
#define MD_LINE_INDEX_H
#include <stddef.h>
#include <stdint.h>
#define SASHIDOWN_MAX_LINE_INDEX 16384
typedef struct {
uint32_t *off; /* line-start offsets; off[n] is end sentinel */
uint32_t n; /* number of line starts (off[0]..off[n-1]) */
uint32_t cur; /* sequential cursor: line index for last lookup */
uint32_t cap; /* slots in off[] (must be >= n+1 when valid) */
int valid; /* 0 if doc exceeded index capacity / OOM */
int owned; /* 1 if off was malloc'd (stream path) */
} line_index_t;
/* Stack-backed index for zero-malloc parse/convert hot path. */
#define LINE_INDEX_STACK(name) \
uint32_t name##_off_[SASHIDOWN_MAX_LINE_INDEX + 1]; \
line_index_t name = { \
.off = name##_off_, .n = 0, .cur = 0, \
.cap = SASHIDOWN_MAX_LINE_INDEX + 1, .valid = 1, .owned = 0 \
}
int line_index_init_dynamic(line_index_t *ix, uint32_t initial_cap);
void line_index_free(line_index_t *ix);
void line_index_build(const char *input, size_t len, line_index_t *ix);
/* Extend for input[old_len..new_len). Returns absolute offset after the last
* newline in that range, or 0 if none. */
size_t line_index_extend(line_index_t *ix, const char *input,
size_t old_len, size_t new_len);
void line_index_seek(line_index_t *ix, size_t pos);
size_t line_end_ix(line_index_t *ix, const char *input, size_t len, size_t pos);
size_t skip_line_ix(line_index_t *ix, const char *input, size_t len, size_t pos);
#endif /* MD_LINE_INDEX_H */
+1202
View File
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
#ifndef MD_RENDER_H
#define MD_RENDER_H
#include "sashidown.h"
#include <stddef.h>
#include <stdint.h>
typedef enum {
LK_EMPTY,
LK_FENCE,
LK_HEADING,
LK_HR,
LK_BQ,
LK_UL,
LK_OL,
LK_LATEX_LINE,
LK_LATEX_BLOCK,
LK_PARA,
} line_kind_t;
typedef struct {
line_kind_t kind;
size_t content_start;
size_t content_end;
int heading_level;
int fence_len;
char fence_char; /* '`' or '~' */
size_t info_start; /* fence info string */
size_t info_end;
int task; /* 0=none, 1=unchecked, 2=checked */
} line_info_t;
/*
* [CST State Q] — open-block environment registers (finite CST state).
* Tracks which block containers are currently open so the transducer can
* emit close tags without an AST. Constant size; independent of |B|.
*/
typedef struct {
uint8_t ul;
uint8_t ol;
uint8_t bq;
uint8_t para;
} block_env_t;
/*
* [CST State Q] — full confirmation-state transducer state.
* Holds block_env plus soft-window / delayed-commit registers (fence,
* LaTeX, pending line, table gate). Maps to CST state Q in the paper:
* Scan(B[0..stop)) advances Q and emits only confirmed Y.
*/
typedef struct {
block_env_t block; /* [CST State Q] nested open-block bits */
int pending_valid;
size_t pending_pos;
size_t pending_le;
line_info_t pending_li;
int in_fence;
int fence_len;
char fence_char;
size_t fence_info_start;
size_t fence_info_end;
int in_latex_block;
size_t latex_start;
uint8_t para_hard_break;
/* Streaming table (scheme C): after header+sep committed, body rows
* emit immediately; close on empty / non-pipe / final. */
int in_table;
int table_cols;
/* Inline emphasis/code spans an open <p> across soft line breaks. */
uint8_t inline_bold;
uint8_t inline_italic;
uint8_t inline_code;
uint8_t inline_latex;
uint8_t inline_strike;
} sashidown_render_state_t;
void sashidown_render_state_init(sashidown_render_state_t *state);
/* Render input[start..stop) or full len when is_final. Updates *pos_io. */
int sashidown_render_incremental(
sashidown_ctx_t *restrict ctx,
sashidown_render_state_t *restrict state,
const char *restrict input, size_t len,
size_t *pos_io,
size_t stop,
int is_final);
int sashidown_render(sashidown_ctx_t *ctx, const char *input, size_t len);
#endif /* MD_RENDER_H */
+559
View File
@@ -0,0 +1,559 @@
#include "render_ansi.h"
#include "ansi_style.h"
#include "block_scan.h"
#include "table_stats.h"
#include "fsm.h"
#include <stdio.h>
#include <string.h>
typedef struct {
uint8_t bold;
uint8_t italic;
uint8_t code;
uint8_t latex;
uint8_t strike;
} ansi_inline_t;
typedef struct {
uint8_t ul;
uint8_t ol;
uint8_t bq;
uint8_t para;
int ol_num;
} ansi_env_t;
static void ansi_s(sashidown_ctx_t *ctx, const char *s)
{
if (s && s[0])
fsm_append_str(ctx, s);
}
static void ansi_close_lists(ansi_env_t *env, sashidown_ctx_t *ctx)
{
(void)ctx;
env->ul = 0;
env->ol = 0;
env->ol_num = 0;
}
static void ansi_close_para(ansi_env_t *env, sashidown_ctx_t *ctx)
{
if (env->para) {
fsm_append(ctx, '\n');
env->para = 0;
}
}
static void ansi_close_all(ansi_env_t *env, sashidown_ctx_t *ctx)
{
ansi_close_lists(env, ctx);
ansi_close_para(env, ctx);
env->bq = 0;
}
static int ansi_render_link(sashidown_ctx_t *ctx, const ansi_theme_t *th,
const char *input, size_t len,
size_t *pos, int is_image)
{
size_t i = *pos + 1;
if (is_image) {
if (i >= len || input[i] != '[')
return 0;
i++;
}
size_t text_start = i;
i = block_scan_until_byte(input, len, i, ']');
if (i >= len || input[i] != ']') return 0;
size_t text_end = i;
i++;
if (i >= len || input[i] != '(') return 0;
i++;
size_t url_start = i;
i = block_scan_until_byte(input, len, i, ')');
if (i >= len) return 0;
if (is_image) {
ansi_s(ctx, th->image_on);
fsm_append_lit(ctx, "[img:");
fsm_append_len(ctx, input + text_start, text_end - text_start);
fsm_append_lit(ctx, "](");
ansi_s(ctx, th->url_on);
fsm_append_len(ctx, input + url_start, i - url_start);
ansi_s(ctx, th->url_off);
fsm_append(ctx, ')');
ansi_s(ctx, th->image_off);
} else {
ansi_s(ctx, th->link_on);
fsm_append_len(ctx, input + text_start, text_end - text_start);
ansi_s(ctx, th->link_off);
fsm_append_lit(ctx, " (");
ansi_s(ctx, th->url_on);
fsm_append_len(ctx, input + url_start, i - url_start);
ansi_s(ctx, th->url_off);
fsm_append(ctx, ')');
}
*pos = i + 1;
return 1;
}
static void ansi_render_inline(sashidown_ctx_t *ctx, const ansi_theme_t *th,
const char *input, size_t len,
size_t start, size_t end)
{
ansi_inline_t st = {0, 0, 0, 0, 0};
size_t i = start;
while (i < end) {
if (st.latex) {
size_t j = block_scan_until_byte(input, end, i, '$');
if (j > i)
fsm_append_len(ctx, input + i, j - i);
if (j < end) {
fsm_append(ctx, '$');
ansi_s(ctx, th->latex_off);
st.latex = 0;
i = j + 1;
} else {
i = j;
}
continue;
}
if (st.code) {
size_t j = block_scan_until_byte(input, end, i, '`');
if (j > i)
fsm_append_len(ctx, input + i, j - i);
if (j < end) {
ansi_s(ctx, th->code_off);
st.code = 0;
i = j + 1;
} else {
i = j;
}
continue;
}
unsigned char ch = (unsigned char)input[i];
if ((ch & 0x80) == 0) {
size_t j = block_scan_plain_ascii(input, end, i);
if (j > i) {
fsm_append_len(ctx, input + i, j - i);
i = j;
if (i >= end)
break;
ch = (unsigned char)input[i];
}
} else {
size_t j = block_scan_utf8_run(input, end, i);
if (j > i) {
fsm_append_len(ctx, input + i, j - i);
i = j;
continue;
}
}
if (ch == '$' && i + 1 < end && input[i + 1] == '$') {
size_t j = block_scan_until_2byte(input, end, i + 2, '$', '$');
if (j < end)
j += 2;
ansi_s(ctx, th->latex_on);
fsm_append_len(ctx, input + i, j - i);
ansi_s(ctx, th->latex_off);
i = j;
continue;
}
if (ch == '$') {
ansi_s(ctx, th->latex_on);
fsm_append(ctx, '$');
st.latex = 1;
i++;
continue;
}
if (ch == '!' && i + 1 < end && input[i + 1] == '[') {
if (ansi_render_link(ctx, th, input, len, &i, 1))
continue;
}
if (ch == '[') {
if (ansi_render_link(ctx, th, input, len, &i, 0))
continue;
}
if (ch == '\\' && i + 1 < end) {
unsigned char next = (unsigned char)input[i + 1];
if ((next >= 0x21 && next <= 0x2F) ||
(next >= 0x3A && next <= 0x40) ||
(next >= 0x5B && next <= 0x60) ||
(next >= 0x7B && next <= 0x7E)) {
fsm_append(ctx, (char)next);
i += 2;
continue;
}
}
if ((ch == '*' || ch == '_') && i + 1 < end && input[i + 1] == (char)ch) {
ansi_s(ctx, st.bold ? th->strong_off : th->strong_on);
st.bold = !st.bold;
i += 2;
continue;
}
if (ch == '~' && i + 1 < end && input[i + 1] == '~') {
ansi_s(ctx, st.strike ? th->strike_off : th->strike_on);
st.strike = !st.strike;
i += 2;
continue;
}
if (ch == '*' || ch == '_') {
ansi_s(ctx, st.italic ? th->emph_off : th->emph_on);
st.italic = !st.italic;
i++;
continue;
}
if (ch == '`') {
ansi_s(ctx, th->code_on);
st.code = 1;
i++;
continue;
}
fsm_append(ctx, (char)ch);
i++;
}
if (st.bold) ansi_s(ctx, th->strong_off);
if (st.italic) ansi_s(ctx, th->emph_off);
if (st.code) ansi_s(ctx, th->code_off);
if (st.strike) ansi_s(ctx, th->strike_off);
if (st.latex) ansi_s(ctx, th->latex_off);
}
static void trim_cell(const char *input, size_t *start, size_t *end)
{
while (*start < *end && input[*start] == ' ')
(*start)++;
while (*end > *start && input[*end - 1] == ' ')
(*end)--;
}
static void ansi_table_row_at(sashidown_ctx_t *ctx, const ansi_theme_t *th,
const char *input, size_t len,
size_t pos, size_t le, int cols, int is_header)
{
size_t i = pos;
while (i < le && (input[i] == ' ' || input[i] == '\t')) i++;
if (i < le && input[i] == '|')
i++;
for (int col = 0; col < cols; col++) {
size_t cs = i;
while (i < le && input[i] != '|') i++;
size_t ce = i;
trim_cell(input, &cs, &ce);
if (col)
ansi_s(ctx, th->sep);
if (is_header)
ansi_s(ctx, th->th_on);
ansi_render_inline(ctx, th, input, len, cs, ce);
if (is_header)
ansi_s(ctx, th->th_off);
if (i < le && input[i] == '|') i++;
}
fsm_append(ctx, '\n');
}
/* Scheme C (batch): header+sep commit, then body rows one-pass. Returns 1 if
* a table was consumed (*pos_io advanced past body). */
static int ansi_try_consume_table(sashidown_ctx_t *ctx, const ansi_theme_t *th,
ansi_env_t *env, const char *input, size_t len,
size_t pos, size_t le, size_t *pos_io)
{
int has_pipe = 0;
int cols = table_count_cols(input + pos, le - pos, &has_pipe);
if (!has_pipe)
return 0;
if (le >= len || input[le] != '\n')
return 0;
size_t next = le + 1;
if (next >= len)
return 0;
size_t nle = block_line_end(ctx, input, len, next);
if (!table_is_separator_row(input + next, nle - next)) {
block_line_seek(ctx, pos);
return 0;
}
ansi_close_all(env, ctx);
ansi_table_row_at(ctx, th, input, len, pos, le, cols, 1);
pos = block_skip_line(ctx, input, len, next);
while (pos < len) {
size_t ble = block_line_end(ctx, input, len, pos);
int empty = (pos == ble || (pos + 1 == ble && input[pos] == '\r'));
if (empty)
break;
size_t blen = ble - pos;
if (!memchr(input + pos, '|', blen) ||
table_is_separator_row(input + pos, blen))
break;
ansi_table_row_at(ctx, th, input, len, pos, ble, cols, 0);
pos = block_skip_line(ctx, input, len, pos);
}
fsm_append(ctx, '\n');
*pos_io = pos;
return 1;
}
static void ansi_render_codeblock(sashidown_ctx_t *ctx, const ansi_theme_t *th,
const char *input, size_t len,
size_t *pos, int fence_len, char fence_char)
{
*pos = block_skip_line(ctx, input, len, *pos);
ansi_s(ctx, th->fence_on);
while (*pos < len) {
int fl = 0;
char fc = 0;
if (block_is_fence(input, len, *pos, &fl, &fc) &&
fc == fence_char && fl >= fence_len) {
size_t is, ie;
block_fence_info_range(input, len, *pos, fl, fc, &is, &ie);
if (ie <= is) {
*pos = block_skip_line(ctx, input, len, *pos);
break;
}
}
size_t le = block_line_end(ctx, input, len, *pos);
fsm_append_lit(ctx, " ");
fsm_append_len(ctx, input + *pos, le - *pos);
fsm_append(ctx, '\n');
*pos = block_skip_line(ctx, input, len, *pos);
}
ansi_s(ctx, th->fence_off);
fsm_append(ctx, '\n');
}
static void ansi_render_latex_block(sashidown_ctx_t *ctx, const ansi_theme_t *th,
const char *input, size_t len,
size_t *pos)
{
size_t i = *pos + 2;
size_t start = i;
while (i + 1 < len) {
if (input[i] == '$' && input[i + 1] == '$') {
ansi_s(ctx, th->latex_on);
fsm_append_lit(ctx, "$$");
if (i > start)
fsm_append_len(ctx, input + start, i - start);
fsm_append_lit(ctx, "$$");
ansi_s(ctx, th->latex_off);
fsm_append(ctx, '\n');
i += 2;
if (i < len && input[i] == '\n') i++;
*pos = i;
return;
}
i++;
}
ansi_s(ctx, th->latex_on);
fsm_append_lit(ctx, "$$");
if (i > start)
fsm_append_len(ctx, input + start, (i < len ? i : len) - start);
fsm_append_lit(ctx, "$$");
ansi_s(ctx, th->latex_off);
fsm_append(ctx, '\n');
*pos = len;
}
static int sashidown_render_ansi_theme(sashidown_ctx_t *ctx, const char *input, size_t len,
const ansi_theme_t *th)
{
ansi_env_t env;
memset(&env, 0, sizeof(env));
size_t pos = 0;
fsm_write_begin(ctx);
while (pos < len) {
size_t le = block_line_end(ctx, input, len, pos);
if (pos == le || (pos + 1 == le && input[pos] == '\r')) {
ansi_close_all(&env, ctx);
if (env.bq)
env.bq = 0;
fsm_append(ctx, '\n');
pos = block_skip_line(ctx, input, len, pos);
continue;
}
if (memchr(input + pos, '|', le - pos) != NULL) {
size_t newt = 0;
if (ansi_try_consume_table(ctx, th, &env, input, len, pos, le, &newt)) {
pos = newt;
block_line_seek(ctx, pos);
continue;
}
}
line_info_t li = block_classify_line(ctx, input, len, pos, le);
if (li.kind == LK_PARA) {
line_info_t sli;
size_t send = 0;
int sr = block_try_setext(ctx, input, len, pos, le, len, 1, &sli, &send);
if (sr > 0) {
int lvl = sli.heading_level;
if (lvl < 1) lvl = 1;
if (lvl > 6) lvl = 6;
ansi_close_all(&env, ctx);
ansi_s(ctx, th->heading[lvl]);
ansi_render_inline(ctx, th, input, len, sli.content_start, sli.content_end);
ansi_s(ctx, th->heading_end);
fsm_append(ctx, '\n');
if (lvl <= 2)
fsm_append(ctx, '\n');
pos = send;
block_line_seek(ctx, pos);
continue;
}
}
switch (li.kind) {
case LK_EMPTY:
ansi_close_all(&env, ctx);
if (env.bq)
env.bq = 0;
fsm_append(ctx, '\n');
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_HR:
ansi_close_all(&env, ctx);
fsm_append_lit(ctx, "---\n\n");
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_FENCE:
ansi_close_all(&env, ctx);
ansi_render_codeblock(ctx, th, input, len, &pos, li.fence_len, li.fence_char);
break;
case LK_LATEX_LINE:
ansi_close_all(&env, ctx);
ansi_s(ctx, th->latex_on);
fsm_append_lit(ctx, "$$");
fsm_append_len(ctx, input + li.content_start,
li.content_end - li.content_start);
fsm_append_lit(ctx, "$$");
ansi_s(ctx, th->latex_off);
fsm_append(ctx, '\n');
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_LATEX_BLOCK:
ansi_close_all(&env, ctx);
ansi_render_latex_block(ctx, th, input, len, &pos);
break;
case LK_HEADING: {
int lvl = li.heading_level;
if (lvl < 1) lvl = 1;
if (lvl > 6) lvl = 6;
ansi_close_all(&env, ctx);
ansi_s(ctx, th->heading[lvl]);
ansi_render_inline(ctx, th, input, len, li.content_start, le);
ansi_s(ctx, th->heading_end);
fsm_append(ctx, '\n');
if (lvl <= 2)
fsm_append(ctx, '\n');
pos = block_skip_line(ctx, input, len, pos);
break;
}
case LK_BQ:
ansi_close_lists(&env, ctx);
env.bq = 1;
ansi_s(ctx, th->bq_prefix);
ansi_render_inline(ctx, th, input, len, li.content_start, le);
fsm_append(ctx, '\n');
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_UL:
if (env.ol) { env.ol = 0; env.ol_num = 0; }
env.ul = 1;
ansi_s(ctx, th->bullet);
if (li.task == 1)
fsm_append_lit(ctx, "[ ] ");
else if (li.task == 2)
fsm_append_lit(ctx, "[x] ");
ansi_render_inline(ctx, th, input, len, li.content_start, le);
ansi_s(ctx, th->bullet_end);
fsm_append(ctx, '\n');
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_OL: {
int num = 0;
size_t cs = li.content_start;
block_is_ol(input, len, pos, &cs, &num);
if (env.ul) env.ul = 0;
env.ol = 1;
char buf[32];
int n = snprintf(buf, sizeof(buf), "%d. ", num > 0 ? num : ++env.ol_num);
ansi_s(ctx, th->ol_on);
if (n > 0)
fsm_append_len(ctx, buf, (size_t)n);
ansi_s(ctx, th->ol_off);
ansi_render_inline(ctx, th, input, len, li.content_start, le);
fsm_append(ctx, '\n');
pos = block_skip_line(ctx, input, len, pos);
break;
}
case LK_PARA:
default:
if (env.bq) env.bq = 0;
ansi_close_lists(&env, ctx);
if (!env.para) {
env.para = 1;
} else {
fsm_append(ctx, '\n');
}
ansi_render_inline(ctx, th, input, len, pos, le);
pos = block_skip_line(ctx, input, len, pos);
if (pos >= len) {
ansi_close_para(&env, ctx);
} else {
size_t nle = block_line_end(ctx, input, len, pos);
line_info_t nli = block_classify_line(ctx, input, len, pos, nle);
if (nli.kind != LK_PARA)
ansi_close_para(&env, ctx);
}
break;
}
if (ctx->overflow) {
fsm_write_end(ctx);
return -1;
}
}
ansi_close_all(&env, ctx);
fsm_write_end(ctx);
return ctx->overflow ? -1 : 0;
}
int sashidown_render_ansi(sashidown_ctx_t *ctx, const char *input, size_t len)
{
return sashidown_render_ansi_theme(ctx, input, len, ansi_theme_color());
}
int sashidown_render_ansi_plain(sashidown_ctx_t *ctx, const char *input, size_t len)
{
return sashidown_render_ansi_theme(ctx, input, len, ansi_theme_plain());
}
+11
View File
@@ -0,0 +1,11 @@
#ifndef SASHIDOWN_RENDER_ANSI_H
#define SASHIDOWN_RENDER_ANSI_H
#include "sashidown.h"
/* Default: built-in colorful theme. */
int sashidown_render_ansi(sashidown_ctx_t *ctx, const char *input, size_t len);
/* Monochrome / attribute-only theme. */
int sashidown_render_ansi_plain(sashidown_ctx_t *ctx, const char *input, size_t len);
#endif
+620
View File
@@ -0,0 +1,620 @@
#include "render_json.h"
#include "block_scan.h"
#include "table_stats.h"
#include "fsm.h"
#include <stdio.h>
#include <string.h>
typedef struct {
uint8_t bold;
uint8_t italic;
uint8_t code;
uint8_t latex;
uint8_t strike;
} json_inline_t;
typedef struct {
uint8_t ul;
uint8_t ol;
uint8_t bq;
uint8_t para;
int need_comma;
} json_env_t;
static void json_comma(sashidown_ctx_t *ctx, json_env_t *env)
{
if (env->need_comma)
fsm_append(ctx, ',');
env->need_comma = 1;
}
static void json_escape(sashidown_ctx_t *ctx, const char *s, size_t n)
{
size_t i = 0;
while (i < n) {
size_t j = i;
while (j < n) {
if (CH_JSON_ESCAPE[(unsigned char)s[j]])
break;
j++;
}
if (j > i)
fsm_append_len(ctx, s + i, j - i);
if (j >= n)
break;
switch (s[j]) {
case '"': fsm_append_lit(ctx, "\\\""); break;
case '\\': fsm_append_lit(ctx, "\\\\"); break;
case '\n': fsm_append_lit(ctx, "\\n"); break;
case '\r': fsm_append_lit(ctx, "\\r"); break;
case '\t': fsm_append_lit(ctx, "\\t"); break;
default: {
char buf[7];
int m = snprintf(buf, sizeof(buf), "\\u%04x", (unsigned char)s[j]);
if (m > 0)
fsm_append_len(ctx, buf, (size_t)m);
break;
}
}
i = j + 1;
}
}
static void json_event_simple(sashidown_ctx_t *ctx, json_env_t *env, const char *t)
{
json_comma(ctx, env);
fsm_append_lit(ctx, "{\"t\":\"");
fsm_append_str(ctx, t);
fsm_append_lit(ctx, "\"}");
}
static void json_event_text(sashidown_ctx_t *ctx, json_env_t *env,
const char *s, size_t n)
{
if (n == 0)
return;
json_comma(ctx, env);
fsm_append_lit(ctx, "{\"t\":\"text\",\"v\":\"");
json_escape(ctx, s, n);
fsm_append_lit(ctx, "\"}");
}
static void json_event_heading(sashidown_ctx_t *ctx, json_env_t *env, int level)
{
json_comma(ctx, env);
char buf[48];
int n = snprintf(buf, sizeof(buf), "{\"t\":\"heading\",\"level\":%d}", level);
if (n > 0)
fsm_append_len(ctx, buf, (size_t)n);
}
static void json_close_lists(sashidown_ctx_t *ctx, json_env_t *env)
{
if (env->ul) {
json_event_simple(ctx, env, "ul_end");
env->ul = 0;
}
if (env->ol) {
json_event_simple(ctx, env, "ol_end");
env->ol = 0;
}
}
static void json_close_para(sashidown_ctx_t *ctx, json_env_t *env)
{
if (env->para) {
json_event_simple(ctx, env, "para_end");
env->para = 0;
}
}
static void json_close_bq(sashidown_ctx_t *ctx, json_env_t *env)
{
if (env->bq) {
json_event_simple(ctx, env, "bq_end");
env->bq = 0;
}
}
static void json_close_all(sashidown_ctx_t *ctx, json_env_t *env)
{
json_close_lists(ctx, env);
json_close_para(ctx, env);
json_close_bq(ctx, env);
}
static int json_render_link(sashidown_ctx_t *ctx, json_env_t *env,
const char *input, size_t len,
size_t *pos, int is_image)
{
size_t i = *pos + 1;
if (is_image) {
if (i >= len || input[i] != '[')
return 0;
i++;
}
size_t text_start = i;
i = block_scan_until_byte(input, len, i, ']');
if (i >= len || input[i] != ']') return 0;
size_t text_end = i;
i++;
if (i >= len || input[i] != '(') return 0;
i++;
size_t url_start = i;
i = block_scan_until_byte(input, len, i, ')');
if (i >= len) return 0;
json_comma(ctx, env);
if (is_image)
fsm_append_lit(ctx, "{\"t\":\"image\",\"alt\":\"");
else
fsm_append_lit(ctx, "{\"t\":\"link\",\"text\":\"");
json_escape(ctx, input + text_start, text_end - text_start);
fsm_append_lit(ctx, "\",\"href\":\"");
json_escape(ctx, input + url_start, i - url_start);
fsm_append_lit(ctx, "\"}");
*pos = i + 1;
return 1;
}
static void json_flush_text(sashidown_ctx_t *ctx, json_env_t *env,
const char *input, size_t *text_start, size_t i)
{
if (i > *text_start)
json_event_text(ctx, env, input + *text_start, i - *text_start);
*text_start = i;
}
static void json_render_inline(sashidown_ctx_t *ctx, json_env_t *env,
const char *input, size_t len,
size_t start, size_t end)
{
json_inline_t st = {0, 0, 0, 0, 0};
size_t i = start;
size_t text_start = start;
while (i < end) {
if (st.latex) {
size_t j = block_scan_until_byte(input, end, i, '$');
if (j < end) {
json_flush_text(ctx, env, input, &text_start, i);
/* include opening $ already emitted into latex stream via text */
json_event_text(ctx, env, input + i - 1, (j + 1) - (i - 1));
st.latex = 0;
i = j + 1;
text_start = i;
} else {
i = j;
}
continue;
}
if (st.code) {
size_t j = block_scan_until_byte(input, end, i, '`');
if (j > i)
json_event_text(ctx, env, input + i, j - i);
if (j < end) {
json_event_simple(ctx, env, "code_off");
st.code = 0;
i = j + 1;
text_start = i;
} else {
i = j;
text_start = i;
}
continue;
}
unsigned char ch = (unsigned char)input[i];
if ((ch & 0x80) == 0) {
size_t j = block_scan_plain_ascii(input, end, i);
if (j > i) {
i = j;
if (i >= end)
break;
ch = (unsigned char)input[i];
}
} else {
size_t j = block_scan_utf8_run(input, end, i);
if (j > i) {
i = j;
continue;
}
}
if (ch == '$' && i + 1 < end && input[i + 1] == '$') {
size_t j = block_scan_until_2byte(input, end, i + 2, '$', '$');
if (j < end)
j += 2;
json_flush_text(ctx, env, input, &text_start, i);
json_event_text(ctx, env, input + i, j - i);
i = j;
text_start = i;
continue;
}
if (ch == '$') {
json_flush_text(ctx, env, input, &text_start, i);
st.latex = 1;
i++;
text_start = i;
continue;
}
if (ch == '!' && i + 1 < end && input[i + 1] == '[') {
size_t save = i;
json_flush_text(ctx, env, input, &text_start, i);
if (json_render_link(ctx, env, input, len, &i, 1)) {
text_start = i;
continue;
}
i = save;
}
if (ch == '[') {
size_t save = i;
json_flush_text(ctx, env, input, &text_start, i);
if (json_render_link(ctx, env, input, len, &i, 0)) {
text_start = i;
continue;
}
i = save;
}
if (ch == '\\' && i + 1 < end) {
unsigned char next = (unsigned char)input[i + 1];
if ((next >= 0x21 && next <= 0x2F) ||
(next >= 0x3A && next <= 0x40) ||
(next >= 0x5B && next <= 0x60) ||
(next >= 0x7B && next <= 0x7E)) {
json_flush_text(ctx, env, input, &text_start, i);
json_event_text(ctx, env, input + i + 1, 1);
i += 2;
text_start = i;
continue;
}
}
if ((ch == '*' || ch == '_') && i + 1 < end && input[i + 1] == (char)ch) {
json_flush_text(ctx, env, input, &text_start, i);
json_event_simple(ctx, env, st.bold ? "strong_off" : "strong_on");
st.bold = !st.bold;
i += 2;
text_start = i;
continue;
}
if (ch == '~' && i + 1 < end && input[i + 1] == '~') {
json_flush_text(ctx, env, input, &text_start, i);
json_event_simple(ctx, env, st.strike ? "strike_off" : "strike_on");
st.strike = !st.strike;
i += 2;
text_start = i;
continue;
}
if (ch == '*' || ch == '_') {
json_flush_text(ctx, env, input, &text_start, i);
json_event_simple(ctx, env, st.italic ? "emph_off" : "emph_on");
st.italic = !st.italic;
i++;
text_start = i;
continue;
}
if (ch == '`') {
json_flush_text(ctx, env, input, &text_start, i);
json_event_simple(ctx, env, "code_on");
st.code = 1;
i++;
text_start = i;
continue;
}
/* special char that was a scan stop — keep accumulating as text */
i++;
}
json_flush_text(ctx, env, input, &text_start, i);
if (st.bold) json_event_simple(ctx, env, "strong_off");
if (st.italic) json_event_simple(ctx, env, "emph_off");
if (st.code) json_event_simple(ctx, env, "code_off");
if (st.strike) json_event_simple(ctx, env, "strike_off");
}
static void trim_cell(const char *input, size_t *start, size_t *end)
{
while (*start < *end && input[*start] == ' ')
(*start)++;
while (*end > *start && input[*end - 1] == ' ')
(*end)--;
}
static void json_table_row_at(sashidown_ctx_t *ctx, json_env_t *env,
const char *input, size_t len,
size_t pos, size_t le, int cols, int is_header)
{
json_event_simple(ctx, env, is_header ? "tr_header" : "tr");
size_t i = pos;
while (i < le && (input[i] == ' ' || input[i] == '\t')) i++;
if (i < le && input[i] == '|')
i++;
for (int col = 0; col < cols; col++) {
size_t cs = i;
while (i < le && input[i] != '|') i++;
size_t ce = i;
trim_cell(input, &cs, &ce);
json_event_simple(ctx, env, is_header ? "th" : "td");
json_render_inline(ctx, env, input, len, cs, ce);
json_event_simple(ctx, env, is_header ? "th_end" : "td_end");
if (i < le && input[i] == '|') i++;
}
json_event_simple(ctx, env, "tr_end");
}
/* Scheme C (batch): header+sep commit, body one-pass. */
static int json_try_consume_table(sashidown_ctx_t *ctx, json_env_t *env,
const char *input, size_t len,
size_t pos, size_t le, size_t *pos_io)
{
int has_pipe = 0;
int cols = table_count_cols(input + pos, le - pos, &has_pipe);
if (!has_pipe)
return 0;
if (le >= len || input[le] != '\n')
return 0;
size_t next = le + 1;
if (next >= len)
return 0;
size_t nle = block_line_end(ctx, input, len, next);
if (!table_is_separator_row(input + next, nle - next)) {
block_line_seek(ctx, pos);
return 0;
}
json_close_all(ctx, env);
json_event_simple(ctx, env, "table");
json_table_row_at(ctx, env, input, len, pos, le, cols, 1);
pos = block_skip_line(ctx, input, len, next);
while (pos < len) {
size_t ble = block_line_end(ctx, input, len, pos);
int empty = (pos == ble || (pos + 1 == ble && input[pos] == '\r'));
if (empty)
break;
size_t blen = ble - pos;
if (!memchr(input + pos, '|', blen) ||
table_is_separator_row(input + pos, blen))
break;
json_table_row_at(ctx, env, input, len, pos, ble, cols, 0);
pos = block_skip_line(ctx, input, len, pos);
}
json_event_simple(ctx, env, "table_end");
*pos_io = pos;
return 1;
}
static void json_render_codeblock(sashidown_ctx_t *ctx, json_env_t *env,
const char *input, size_t len,
size_t *pos, int fence_len, char fence_char)
{
json_event_simple(ctx, env, "fence");
*pos = block_skip_line(ctx, input, len, *pos);
while (*pos < len) {
int fl = 0;
char fc = 0;
if (block_is_fence(input, len, *pos, &fl, &fc) &&
fc == fence_char && fl >= fence_len) {
size_t is, ie;
block_fence_info_range(input, len, *pos, fl, fc, &is, &ie);
if (ie <= is) {
*pos = block_skip_line(ctx, input, len, *pos);
break;
}
}
size_t le = block_line_end(ctx, input, len, *pos);
json_event_text(ctx, env, input + *pos, le - *pos);
if (le < len)
json_event_text(ctx, env, "\n", 1);
*pos = block_skip_line(ctx, input, len, *pos);
}
json_event_simple(ctx, env, "fence_end");
}
static void json_render_latex_block(sashidown_ctx_t *ctx, json_env_t *env,
const char *input, size_t len,
size_t *pos)
{
size_t i = *pos + 2;
size_t start = i;
while (i + 1 < len) {
if (input[i] == '$' && input[i + 1] == '$') {
json_comma(ctx, env);
fsm_append_lit(ctx, "{\"t\":\"latex_block\",\"v\":\"");
json_escape(ctx, input + start, i - start);
fsm_append_lit(ctx, "\"}");
i += 2;
if (i < len && input[i] == '\n') i++;
*pos = i;
return;
}
i++;
}
json_comma(ctx, env);
fsm_append_lit(ctx, "{\"t\":\"latex_block\",\"v\":\"");
json_escape(ctx, input + start, (i < len ? i : len) - start);
fsm_append_lit(ctx, "\"}");
*pos = len;
}
int sashidown_render_json(sashidown_ctx_t *ctx, const char *input, size_t len)
{
json_env_t env;
memset(&env, 0, sizeof(env));
size_t pos = 0;
fsm_write_begin(ctx);
fsm_append(ctx, '[');
while (pos < len) {
size_t le = block_line_end(ctx, input, len, pos);
if (pos == le || (pos + 1 == le && input[pos] == '\r')) {
json_close_all(ctx, &env);
pos = block_skip_line(ctx, input, len, pos);
continue;
}
if (memchr(input + pos, '|', le - pos) != NULL) {
size_t newt = 0;
if (json_try_consume_table(ctx, &env, input, len, pos, le, &newt)) {
pos = newt;
block_line_seek(ctx, pos);
continue;
}
}
line_info_t li = block_classify_line(ctx, input, len, pos, le);
if (li.kind == LK_PARA) {
line_info_t sli;
size_t send = 0;
int sr = block_try_setext(ctx, input, len, pos, le, len, 1, &sli, &send);
if (sr > 0) {
json_close_all(ctx, &env);
json_event_heading(ctx, &env, sli.heading_level);
json_render_inline(ctx, &env, input, len, sli.content_start, sli.content_end);
json_event_simple(ctx, &env, "heading_end");
pos = send;
block_line_seek(ctx, pos);
continue;
}
}
switch (li.kind) {
case LK_EMPTY:
json_close_all(ctx, &env);
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_HR:
json_close_all(ctx, &env);
json_event_simple(ctx, &env, "hr");
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_FENCE:
json_close_all(ctx, &env);
json_render_codeblock(ctx, &env, input, len, &pos, li.fence_len, li.fence_char);
break;
case LK_LATEX_LINE:
json_close_all(ctx, &env);
json_comma(ctx, &env);
fsm_append_lit(ctx, "{\"t\":\"latex_block\",\"v\":\"");
json_escape(ctx, input + li.content_start,
li.content_end - li.content_start);
fsm_append_lit(ctx, "\"}");
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_LATEX_BLOCK:
json_close_all(ctx, &env);
json_render_latex_block(ctx, &env, input, len, &pos);
break;
case LK_HEADING:
json_close_all(ctx, &env);
json_event_heading(ctx, &env, li.heading_level);
json_render_inline(ctx, &env, input, len, li.content_start, le);
json_event_simple(ctx, &env, "heading_end");
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_BQ:
json_close_lists(ctx, &env);
json_close_para(ctx, &env);
if (!env.bq) {
json_event_simple(ctx, &env, "bq");
env.bq = 1;
}
json_event_simple(ctx, &env, "para");
json_render_inline(ctx, &env, input, len, li.content_start, le);
json_event_simple(ctx, &env, "para_end");
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_UL:
json_close_para(ctx, &env);
json_close_bq(ctx, &env);
if (env.ol) {
json_event_simple(ctx, &env, "ol_end");
env.ol = 0;
}
if (!env.ul) {
json_event_simple(ctx, &env, "ul");
env.ul = 1;
}
json_event_simple(ctx, &env, "li");
if (li.task == 1)
json_event_simple(ctx, &env, "task_unchecked");
else if (li.task == 2)
json_event_simple(ctx, &env, "task_checked");
json_render_inline(ctx, &env, input, len, li.content_start, le);
json_event_simple(ctx, &env, "li_end");
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_OL:
json_close_para(ctx, &env);
json_close_bq(ctx, &env);
if (env.ul) {
json_event_simple(ctx, &env, "ul_end");
env.ul = 0;
}
if (!env.ol) {
json_event_simple(ctx, &env, "ol");
env.ol = 1;
}
json_event_simple(ctx, &env, "li");
json_render_inline(ctx, &env, input, len, li.content_start, le);
json_event_simple(ctx, &env, "li_end");
pos = block_skip_line(ctx, input, len, pos);
break;
case LK_PARA:
default:
json_close_bq(ctx, &env);
json_close_lists(ctx, &env);
if (!env.para) {
json_event_simple(ctx, &env, "para");
env.para = 1;
} else {
json_event_text(ctx, &env, "\n", 1);
}
json_render_inline(ctx, &env, input, len, pos, le);
pos = block_skip_line(ctx, input, len, pos);
if (pos >= len) {
json_close_para(ctx, &env);
} else {
size_t nle = block_line_end(ctx, input, len, pos);
line_info_t nli = block_classify_line(ctx, input, len, pos, nle);
if (nli.kind != LK_PARA)
json_close_para(ctx, &env);
}
break;
}
if (ctx->overflow) {
fsm_write_end(ctx);
return -1;
}
}
json_close_all(ctx, &env);
fsm_append(ctx, ']');
fsm_write_end(ctx);
return ctx->overflow ? -1 : 0;
}
+8
View File
@@ -0,0 +1,8 @@
#ifndef SASHIDOWN_RENDER_JSON_H
#define SASHIDOWN_RENDER_JSON_H
#include "sashidown.h"
int sashidown_render_json(sashidown_ctx_t *ctx, const char *input, size_t len);
#endif
+96
View File
@@ -0,0 +1,96 @@
#include "sashidown.h"
#include "line_index.h"
#include "fsm.h"
#include "render.h"
#include "render_ansi.h"
#include "render_json.h"
#include <string.h>
int sashidown_parse(const char *input, size_t len,
char *output, size_t cap, size_t *out_len)
{
if (!input || !output || cap == 0)
return -1;
LINE_INDEX_STACK(ix);
line_index_build(input, len, &ix);
sashidown_ctx_t ctx = {
.input = input,
.input_len = len,
.output = output,
.output_cap = cap,
.output_len = 0,
.write_ptr = NULL,
.write_end = NULL,
.line_ix = &ix,
.overflow = 0,
};
if (sashidown_render(&ctx, input, len) != 0) {
fsm_write_end(&ctx);
return -1;
}
if (ctx.overflow || ctx.output_len + 1 > cap)
return -1;
output[ctx.output_len] = '\0';
if (out_len)
*out_len = ctx.output_len;
return 0;
}
static int convert_with_render(const char *input, size_t len,
char *output, size_t cap, size_t *out_len,
int (*render_fn)(sashidown_ctx_t *, const char *, size_t))
{
if (!input || !output || cap == 0)
return -1;
LINE_INDEX_STACK(ix);
line_index_build(input, len, &ix);
sashidown_ctx_t ctx = {
.input = input,
.input_len = len,
.output = output,
.output_cap = cap,
.output_len = 0,
.write_ptr = NULL,
.write_end = NULL,
.line_ix = &ix,
.overflow = 0,
};
if (render_fn(&ctx, input, len) != 0) {
fsm_write_end(&ctx);
return -1;
}
if (ctx.overflow || ctx.output_len + 1 > cap)
return -1;
output[ctx.output_len] = '\0';
if (out_len)
*out_len = ctx.output_len;
return 0;
}
int sashidown_convert(const char *input, size_t len,
char *output, size_t cap, size_t *out_len,
sashidown_format_t format)
{
switch (format) {
case SASHIDOWN_FMT_HTML:
return sashidown_parse(input, len, output, cap, out_len);
case SASHIDOWN_FMT_ANSI:
return convert_with_render(input, len, output, cap, out_len, sashidown_render_ansi);
case SASHIDOWN_FMT_ANSI_PLAIN:
return convert_with_render(input, len, output, cap, out_len, sashidown_render_ansi_plain);
case SASHIDOWN_FMT_JSON:
return convert_with_render(input, len, output, cap, out_len, sashidown_render_json);
default:
return -1;
}
}
+364
View File
@@ -0,0 +1,364 @@
#include "sashidown.h"
#include "line_index.h"
#include "fsm.h"
#include "render.h"
#include <stdlib.h>
#include <string.h>
#define STREAM_INPUT_INIT 4096
#define STREAM_OUTPUT_INIT 8192
#define STREAM_OUTPUT_MAX (64u * 1024u * 1024u)
#define STREAM_LINE_IX_INIT 256
struct sashidown_stream {
char *input_buf;
size_t input_len;
size_t input_cap;
size_t indexed_len; /* bytes already in line_ix */
size_t render_pos;
size_t last_complete_nl; /* [0, last_complete_nl) ends on line boundary */
sashidown_render_state_t render_state;
line_index_t line_ix;
char *output_buf;
size_t output_cap;
sashidown_ctx_t render_ctx;
sashidown_stream_cb on_html;
void *user;
int finished;
int aborted;
};
static int stream_grow_input(sashidown_stream_t *s, size_t need)
{
if (need <= s->input_cap)
return 0;
size_t cap = s->input_cap ? s->input_cap : STREAM_INPUT_INIT;
while (cap < need)
cap *= 2;
char *p = (char *)realloc(s->input_buf, cap);
if (!p)
return -1;
s->input_buf = p;
s->input_cap = cap;
return 0;
}
static int stream_grow_output(sashidown_stream_t *s, size_t need)
{
if (need <= s->output_cap)
return 0;
size_t cap = s->output_cap ? s->output_cap : STREAM_OUTPUT_INIT;
while (cap < need)
cap *= 2;
if (cap > STREAM_OUTPUT_MAX)
cap = STREAM_OUTPUT_MAX;
if (cap <= s->output_cap)
return -1;
char *p = (char *)realloc(s->output_buf, cap);
if (!p)
return -1;
s->output_buf = p;
s->output_cap = cap;
return 0;
}
static int stream_flush_html(sashidown_stream_t *s)
{
sashidown_ctx_t *ctx = &s->render_ctx;
if (ctx->output_len == 0)
return 0;
if (!s->on_html)
return 0;
int r = s->on_html(ctx->output, ctx->output_len, s->user);
if (r != 0) {
s->aborted = 1;
return -1;
}
ctx->output_len = 0;
return 0;
}
/* Sync line index through input_len; update last_complete_nl. */
static void stream_catchup_index(sashidown_stream_t *s)
{
if (s->indexed_len >= s->input_len)
return;
if (s->indexed_len == 0) {
line_index_build(s->input_buf, s->input_len, &s->line_ix);
size_t last = 0;
if (s->line_ix.valid && s->line_ix.n > 1) {
last = (size_t)s->line_ix.off[s->line_ix.n - 1];
} else {
const char *p = s->input_buf;
const char *end = s->input_buf + s->input_len;
const char *hit;
while ((hit = memchr(p, '\n', (size_t)(end - p))) != NULL) {
last = (size_t)(hit - s->input_buf) + 1;
p = hit + 1;
}
}
if (last)
s->last_complete_nl = last;
} else {
size_t nl = line_index_extend(&s->line_ix, s->input_buf,
s->indexed_len, s->input_len);
if (nl)
s->last_complete_nl = nl;
}
s->indexed_len = s->input_len;
}
/* 1 = overflow (state restored), 0 = ok, -1 = error */
static int stream_render_attempt(sashidown_stream_t *s, size_t stop, int is_final)
{
sashidown_ctx_t *ctx = &s->render_ctx;
size_t saved_pos = s->render_pos;
sashidown_render_state_t saved_state = s->render_state;
ctx->output_len = 0;
ctx->overflow = 0;
fsm_write_begin(ctx);
int r = sashidown_render_incremental(ctx, &s->render_state,
s->input_buf, s->input_len,
&s->render_pos, stop, is_final);
fsm_write_end(ctx);
if (ctx->overflow) {
s->render_pos = saved_pos;
s->render_state = saved_state;
return 1;
}
if (r != 0)
return -1;
return stream_flush_html(s);
}
static size_t stream_soft_stop(sashidown_stream_t *s, size_t from, size_t target)
{
if (from >= target)
return target;
/* Keep each attempt roughly within half the output buffer. */
size_t soft = from + s->output_cap / 2;
if (soft >= target)
return target;
const char *base = s->input_buf + from;
size_t lim = soft - from;
const char *p = base;
const char *end = base + lim;
const char *last = NULL;
const char *hit;
while ((hit = memchr(p, '\n', (size_t)(end - p))) != NULL) {
last = hit;
p = hit + 1;
}
if (last)
return (size_t)(last - s->input_buf) + 1;
/* No newline in soft window — take the next one so we still make progress. */
hit = memchr(s->input_buf + from, '\n', target - from);
if (hit)
return (size_t)(hit - s->input_buf) + 1;
return target;
}
static size_t stream_next_line_stop(sashidown_stream_t *s, size_t from, size_t limit)
{
if (from >= limit)
return limit;
const char *hit = memchr(s->input_buf + from, '\n', limit - from);
if (hit)
return (size_t)(hit - s->input_buf) + 1;
return limit;
}
/* Render up to pass_stop (is_final as given). Grow/shrink on overflow.
* Returns 0 on success, -1 on error. */
static int stream_render_window(sashidown_stream_t *s, size_t pass_stop, int is_final)
{
sashidown_ctx_t *ctx = &s->render_ctx;
for (;;) {
int ar = stream_render_attempt(s, pass_stop, is_final);
if (ar == 0)
return 0;
if (ar < 0)
return -1;
if (stream_grow_output(s, s->output_cap * 2) == 0) {
ctx->output = s->output_buf;
ctx->output_cap = s->output_cap;
continue;
}
size_t one = stream_next_line_stop(s, s->render_pos, pass_stop);
if (one <= s->render_pos || one >= pass_stop)
return -1;
pass_stop = one;
is_final = 0;
}
}
static int stream_render_pass(sashidown_stream_t *s, size_t stop, int is_final)
{
if (s->aborted)
return -1;
if (!is_final && stop <= s->render_pos)
return 0;
sashidown_ctx_t *ctx = &s->render_ctx;
ctx->input = s->input_buf;
ctx->input_len = s->input_len;
size_t target = is_final ? s->input_len : stop;
/* Common case: one or a few lines already fit in the output buffer. */
if (target - s->render_pos <= s->output_cap / 4) {
int pass_final = is_final && (target >= s->input_len);
size_t before = s->render_pos;
if (stream_render_window(s, target, pass_final) != 0)
return -1;
if (s->render_pos > before || pass_final)
return (is_final && !pass_final)
? stream_render_window(s, s->input_len, 1)
: 0;
if (!is_final)
return 0; /* deferred (incomplete table, etc.) */
/* fall through to spanned finalization */
}
while (s->render_pos < target) {
size_t before = s->render_pos;
size_t pass_stop = stream_soft_stop(s, s->render_pos, target);
int pass_final = is_final && (pass_stop >= s->input_len);
if (stream_render_window(s, pass_stop, pass_final) != 0)
return -1;
if (s->render_pos > before) {
if (pass_final)
return 0;
continue;
}
/* No progress: soft window may have deferred a table. Retry full target. */
if (pass_stop < target) {
pass_final = is_final && (target >= s->input_len);
if (stream_render_window(s, target, pass_final) != 0)
return -1;
if (s->render_pos > before) {
if (pass_final)
return 0;
continue;
}
}
/* Still stuck — waiting for more input (incomplete table, etc.). */
if (!is_final)
return 0;
return -1;
}
if (!is_final)
return 0;
/* EOF reached without a finalizing pass — close open blocks. */
return stream_render_window(s, s->input_len, 1);
}
sashidown_stream_t *sashidown_stream_create(const sashidown_stream_opts_t *opts)
{
if (!opts || !opts->on_html)
return NULL;
sashidown_stream_t *s = (sashidown_stream_t *)calloc(1, sizeof(*s));
if (!s)
return NULL;
size_t out_cap = opts->output_buf_size ? opts->output_buf_size : STREAM_OUTPUT_INIT;
if (out_cap > STREAM_OUTPUT_MAX)
out_cap = STREAM_OUTPUT_MAX;
s->output_cap = out_cap;
s->output_buf = (char *)malloc(s->output_cap);
if (!s->output_buf) {
free(s);
return NULL;
}
if (line_index_init_dynamic(&s->line_ix, STREAM_LINE_IX_INIT) != 0) {
free(s->output_buf);
free(s);
return NULL;
}
s->on_html = opts->on_html;
s->user = opts->user;
sashidown_render_state_init(&s->render_state);
s->render_ctx.output = s->output_buf;
s->render_ctx.output_cap = s->output_cap;
s->render_ctx.line_ix = &s->line_ix;
return s;
}
int sashidown_stream_feed(sashidown_stream_t *s, const char *chunk, size_t len)
{
if (!s || s->aborted)
return -1;
if (s->finished)
return 0;
if (!chunk && len > 0)
return -1;
if (len == 0)
return 0;
int has_nl = (memchr(chunk, '\n', len) != NULL);
if (stream_grow_input(s, s->input_len + len) != 0)
return -1;
memcpy(s->input_buf + s->input_len, chunk, len);
s->input_len += len;
/* Tiny-chunk fast path: no newline → defer index + render. */
if (!has_nl)
return 0;
stream_catchup_index(s);
if (s->last_complete_nl <= s->render_pos)
return 0;
return stream_render_pass(s, s->last_complete_nl, 0);
}
int sashidown_stream_finish(sashidown_stream_t *s)
{
if (!s || s->aborted)
return -1;
if (s->finished)
return 0;
stream_catchup_index(s);
int r = stream_render_pass(s, s->input_len, 1);
s->finished = 1;
return r;
}
void sashidown_stream_destroy(sashidown_stream_t *s)
{
if (!s)
return;
free(s->input_buf);
free(s->output_buf);
line_index_free(&s->line_ix);
free(s);
}
size_t sashidown_stream_pending(const sashidown_stream_t *s)
{
if (!s || s->input_len <= s->render_pos)
return 0;
return s->input_len - s->render_pos;
}
+137
View File
@@ -0,0 +1,137 @@
#include "table_stats.h"
#include "line_index.h"
#include <string.h>
int table_is_separator_row(const char *line, size_t len)
{
int pipes = 0;
int dashes = 0;
for (size_t i = 0; i < len; i++) {
char c = line[i];
if (c == '|') pipes++;
else if (c == '-' || c == ':' || c == ' ') dashes++;
else if (c != '\r') return 0;
}
return pipes >= 1 && dashes > 0;
}
/* GFM: leading/trailing '|' are delimiters, not empty cells. */
int table_count_cols(const char *line, size_t len, int *has_pipe)
{
if (has_pipe)
*has_pipe = 0;
while (len > 0 && line[len - 1] == '\r')
len--;
size_t start = 0;
size_t end = len;
while (start < end && (line[start] == ' ' || line[start] == '\t'))
start++;
if (start < end && line[start] == '|') {
start++;
if (has_pipe)
*has_pipe = 1;
}
while (end > start && (line[end - 1] == ' ' || line[end - 1] == '\t'))
end--;
if (end > start && line[end - 1] == '|') {
end--;
if (has_pipe)
*has_pipe = 1;
}
if (start >= end)
return 1;
int cols = 1;
for (size_t i = start; i < end; i++) {
if (line[i] == '|') {
cols++;
if (has_pipe)
*has_pipe = 1;
}
}
return cols > SASHIDOWN_MAX_COLS ? SASHIDOWN_MAX_COLS : cols;
}
static size_t te_line_end(sashidown_ctx_t *ctx, const char *input, size_t len, size_t pos)
{
if (ctx && ctx->line_ix)
return line_end_ix((line_index_t *)ctx->line_ix, input, len, pos);
const char *hit = memchr(input + pos, '\n', len - pos);
return hit ? (size_t)(hit - input) : len;
}
static size_t te_skip_line(sashidown_ctx_t *ctx, const char *input, size_t len, size_t pos)
{
if (ctx && ctx->line_ix)
return skip_line_ix((line_index_t *)ctx->line_ix, input, len, pos);
pos = te_line_end(NULL, input, len, pos);
if (pos < len)
pos++;
return pos;
}
static int line_has_pipe_raw(const char *line, size_t len)
{
return memchr(line, '|', len) != NULL;
}
static int try_parse_table(sashidown_ctx_t *ctx, const char *input, size_t len,
size_t line_start, table_entry_t *e)
{
size_t le = te_line_end(ctx, input, len, line_start);
int has_pipe = 0;
int cols = table_count_cols(input + line_start, le - line_start, &has_pipe);
if (!has_pipe)
return 0;
size_t sep = te_skip_line(ctx, input, len, line_start);
if (sep >= len) {
if (ctx && ctx->line_ix)
line_index_seek((line_index_t *)ctx->line_ix, line_start);
return 0;
}
size_t sep_le = te_line_end(ctx, input, len, sep);
if (!table_is_separator_row(input + sep, sep_le - sep)) {
if (ctx && ctx->line_ix)
line_index_seek((line_index_t *)ctx->line_ix, line_start);
return 0;
}
size_t pos = te_skip_line(ctx, input, len, sep);
e->start = line_start;
e->body_start = pos;
e->cols = cols; /* GFM: header determines column count */
e->body_rows = 0;
e->valid = 1;
while (pos < len) {
le = te_line_end(ctx, input, len, pos);
size_t line_len = le - pos;
if (!line_has_pipe_raw(input + pos, line_len))
break;
if (table_is_separator_row(input + pos, line_len))
break;
e->body_rows++;
pos = te_skip_line(ctx, input, len, pos);
}
e->end = pos;
return 1;
}
int table_try_parse_at(sashidown_ctx_t *ctx, const char *input, size_t len,
size_t pos, table_entry_t *e)
{
e->start = 0;
e->end = 0;
e->body_start = 0;
e->cols = 0;
e->body_rows = 0;
e->valid = 0;
return try_parse_table(ctx, input, len, pos, e);
}
+32
View File
@@ -0,0 +1,32 @@
#ifndef MD_TABLE_STATS_H
#define MD_TABLE_STATS_H
#include "sashidown.h"
#include <stddef.h>
#include <stdint.h>
#define SASHIDOWN_MAX_COLS 32
/*
* [CST Pending Window Metadata] — lightweight table descriptor for the
* delayed-commitment pending window. Records header/separator/body bounds
* without caching cells; render rescans once after the separator enters
* the confirmation frontier. No per-cell heap; O(1) registers only.
*/
typedef struct {
size_t start; /* header line start */
size_t end; /* first byte after the table */
size_t body_start; /* first body row (after separator) */
int cols; /* column count from header (GFM) */
int body_rows;
int valid;
} table_entry_t;
int table_is_separator_row(const char *line, size_t len);
int table_count_cols(const char *line, size_t len, int *has_pipe);
/* ctx may be NULL (falls back to memchr); when non-NULL reuses line_index. */
int table_try_parse_at(sashidown_ctx_t *ctx, const char *input, size_t len,
size_t pos, table_entry_t *e);
#endif /* MD_TABLE_STATS_H */