From 1f1fcba7ad7a85d08675b2099c74c1c0ea2808fb Mon Sep 17 00:00:00 2001 From: WHC Date: Mon, 7 Sep 2026 22:42:32 +0800 Subject: [PATCH] Initial CST-Agent: portable Linux/Termux/Windows coding agent. Vendored sashidown, OpenAI-compatible env (OPENAI_*), and bootstrap scripts. Co-authored-by: Cursor --- .gitignore | 25 + CMakeLists.txt | 160 ++ Makefile | 77 + README.md | 198 ++ cmake/mingw-w64-aarch64.cmake | 12 + cmake/mingw-w64-x86_64.cmake | 21 + docs/README-windows.md | 53 + docs/env_openai.md | 73 + docs/eval_tool_cst.md | 73 + docs/theory_tool_cst.md | 60 + docs/windows_selftest.md | 11 + fixtures/mini_calc/Makefile | 15 + fixtures/mini_calc/README.md | 19 + fixtures/mini_calc/TASK.md | 11 + fixtures/mini_calc/calc.c | 58 + fixtures/mini_calc/tests/smoke.sh | 30 + fixtures/snake_cpp/Makefile | 10 + fixtures/snake_cpp/README.md | 51 + fixtures/snake_cpp/TASK.md | 10 + fixtures/snake_cpp/snake.cpp | 224 +++ include/cst_agent.h | 174 ++ include/ctl.h | 98 + include/mm_tool.h | 51 + include/os_compat.h | 68 + scripts/bootstrap_linux.sh | 40 + scripts/bootstrap_termux.sh | 31 + scripts/bootstrap_windows.sh | 48 + scripts/build_third_party_mingw.sh | 139 ++ scripts/e2e_mini_calc.sh | 114 ++ scripts/fetch_busybox_w32.sh | 47 + scripts/package_windows.sh | 18 + scripts/replay_starvation.sh | 8 + scripts/run_windows.bat | 27 + src/agent_loop.c | 1182 +++++++++++ src/agent_store.c | 189 ++ src/creds.c | 140 ++ src/ctl_parse.c | 419 ++++ src/json_baseline.c | 145 ++ src/llm_openai.c | 786 ++++++++ src/main.c | 283 +++ src/mm_tool.c | 410 ++++ src/models.c | 67 + src/os_compat.c | 584 ++++++ src/sandbox.c | 61 + src/session.c | 363 ++++ src/tools/tools.c | 784 ++++++++ src/tui.c | 2761 ++++++++++++++++++++++++++ src/tui_stub.c | 13 + tests/test_ctl_frontier.c | 156 ++ tests/test_json_baseline.c | 55 + tests/test_mm_tool.c | 65 + tests/test_replay_starvation.c | 102 + third_party/busybox/LICENSE.busybox | 2 + third_party/busybox/README.md | 26 + vendor/sashidown/Makefile | 16 + vendor/sashidown/include/sashidown.h | 64 + vendor/sashidown/src/ansi_style.c | 104 + vendor/sashidown/src/ansi_style.h | 48 + vendor/sashidown/src/block_scan.c | 337 ++++ vendor/sashidown/src/block_scan.h | 196 ++ vendor/sashidown/src/compat_cmark.c | 44 + vendor/sashidown/src/fsm.c | 110 + vendor/sashidown/src/fsm.h | 31 + vendor/sashidown/src/line_index.c | 184 ++ vendor/sashidown/src/line_index.h | 40 + vendor/sashidown/src/render.c | 1202 +++++++++++ vendor/sashidown/src/render.h | 90 + vendor/sashidown/src/render_ansi.c | 559 ++++++ vendor/sashidown/src/render_ansi.h | 11 + vendor/sashidown/src/render_json.c | 620 ++++++ vendor/sashidown/src/render_json.h | 8 + vendor/sashidown/src/sashidown.c | 96 + vendor/sashidown/src/stream.c | 364 ++++ vendor/sashidown/src/table_stats.c | 137 ++ vendor/sashidown/src/table_stats.h | 32 + 75 files changed, 14940 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmake/mingw-w64-aarch64.cmake create mode 100644 cmake/mingw-w64-x86_64.cmake create mode 100644 docs/README-windows.md create mode 100644 docs/env_openai.md create mode 100644 docs/eval_tool_cst.md create mode 100644 docs/theory_tool_cst.md create mode 100644 docs/windows_selftest.md create mode 100644 fixtures/mini_calc/Makefile create mode 100644 fixtures/mini_calc/README.md create mode 100644 fixtures/mini_calc/TASK.md create mode 100644 fixtures/mini_calc/calc.c create mode 100755 fixtures/mini_calc/tests/smoke.sh create mode 100644 fixtures/snake_cpp/Makefile create mode 100644 fixtures/snake_cpp/README.md create mode 100644 fixtures/snake_cpp/TASK.md create mode 100644 fixtures/snake_cpp/snake.cpp create mode 100644 include/cst_agent.h create mode 100644 include/ctl.h create mode 100644 include/mm_tool.h create mode 100644 include/os_compat.h create mode 100755 scripts/bootstrap_linux.sh create mode 100755 scripts/bootstrap_termux.sh create mode 100755 scripts/bootstrap_windows.sh create mode 100755 scripts/build_third_party_mingw.sh create mode 100755 scripts/e2e_mini_calc.sh create mode 100755 scripts/fetch_busybox_w32.sh create mode 100755 scripts/package_windows.sh create mode 100755 scripts/replay_starvation.sh create mode 100755 scripts/run_windows.bat create mode 100644 src/agent_loop.c create mode 100644 src/agent_store.c create mode 100644 src/creds.c create mode 100644 src/ctl_parse.c create mode 100644 src/json_baseline.c create mode 100644 src/llm_openai.c create mode 100644 src/main.c create mode 100644 src/mm_tool.c create mode 100644 src/models.c create mode 100644 src/os_compat.c create mode 100644 src/sandbox.c create mode 100644 src/session.c create mode 100644 src/tools/tools.c create mode 100644 src/tui.c create mode 100644 src/tui_stub.c create mode 100644 tests/test_ctl_frontier.c create mode 100644 tests/test_json_baseline.c create mode 100644 tests/test_mm_tool.c create mode 100644 tests/test_replay_starvation.c create mode 100644 third_party/busybox/LICENSE.busybox create mode 100644 third_party/busybox/README.md create mode 100644 vendor/sashidown/Makefile create mode 100644 vendor/sashidown/include/sashidown.h create mode 100644 vendor/sashidown/src/ansi_style.c create mode 100644 vendor/sashidown/src/ansi_style.h create mode 100644 vendor/sashidown/src/block_scan.c create mode 100644 vendor/sashidown/src/block_scan.h create mode 100644 vendor/sashidown/src/compat_cmark.c create mode 100644 vendor/sashidown/src/fsm.c create mode 100644 vendor/sashidown/src/fsm.h create mode 100644 vendor/sashidown/src/line_index.c create mode 100644 vendor/sashidown/src/line_index.h create mode 100644 vendor/sashidown/src/render.c create mode 100644 vendor/sashidown/src/render.h create mode 100644 vendor/sashidown/src/render_ansi.c create mode 100644 vendor/sashidown/src/render_ansi.h create mode 100644 vendor/sashidown/src/render_json.c create mode 100644 vendor/sashidown/src/render_json.h create mode 100644 vendor/sashidown/src/sashidown.c create mode 100644 vendor/sashidown/src/stream.c create mode 100644 vendor/sashidown/src/table_stats.c create mode 100644 vendor/sashidown/src/table_stats.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a808ffe --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..aa04d83 --- /dev/null +++ b/CMakeLists.txt @@ -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}") diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aaf975e --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4bfd916 --- /dev/null +++ b/README.md @@ -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 ` 手打切换 | +| `/model` | **可点选**模型;`/model NAME` 直接设置(进程内) | +| `/compact` | 压缩上下文 | +| `/details` | 展开/折叠全部 tool 输出 | +| `/help` | 帮助 | + +会话落盘:`/.agent/sessions/.jsonl` + `CURRENT`。 + +### 协议 + +- OpenAI 兼容 `POST …/chat/completions`:原生 `tools` / SSE `tool_calls`,answer 轮 `tool_choice:none` +- MiniMax XML `` 作 fallback +- Assistant 仅工具时 `content: null` + `tool_calls`(对齐 OpenCode/OpenAI Chat) + +## CTL tool language + +```text +call +arg + +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 实验)。 diff --git a/cmake/mingw-w64-aarch64.cmake b/cmake/mingw-w64-aarch64.cmake new file mode 100644 index 0000000..c234e46 --- /dev/null +++ b/cmake/mingw-w64-aarch64.cmake @@ -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) diff --git a/cmake/mingw-w64-x86_64.cmake b/cmake/mingw-w64-x86_64.cmake new file mode 100644 index 0000000..c0263eb --- /dev/null +++ b/cmake/mingw-w64-x86_64.cmake @@ -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") diff --git a/docs/README-windows.md b/docs/README-windows.md new file mode 100644 index 0000000..a4e6a3c --- /dev/null +++ b/docs/README-windows.md @@ -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). diff --git a/docs/env_openai.md b/docs/env_openai.md new file mode 100644 index 0000000..a85879b --- /dev/null +++ b/docs/env_openai.md @@ -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 +``` diff --git a/docs/eval_tool_cst.md b/docs/eval_tool_cst.md new file mode 100644 index 0000000..7ce5ec1 --- /dev/null +++ b/docs/eval_tool_cst.md @@ -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」类宣称 diff --git a/docs/theory_tool_cst.md b/docs/theory_tool_cst.md new file mode 100644 index 0000000..626a7d1 --- /dev/null +++ b/docs/theory_tool_cst.md @@ -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 \n +arg \n + +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 确认,禁止半截应用。 diff --git a/docs/windows_selftest.md b/docs/windows_selftest.md new file mode 100644 index 0000000..d84a998 --- /dev/null +++ b/docs/windows_selftest.md @@ -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. diff --git a/fixtures/mini_calc/Makefile b/fixtures/mini_calc/Makefile new file mode 100644 index 0000000..6c2ed3d --- /dev/null +++ b/fixtures/mini_calc/Makefile @@ -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 diff --git a/fixtures/mini_calc/README.md b/fixtures/mini_calc/README.md new file mode 100644 index 0000000..f12dde7 --- /dev/null +++ b/fixtures/mini_calc/README.md @@ -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 +``` diff --git a/fixtures/mini_calc/TASK.md b/fixtures/mini_calc/TASK.md new file mode 100644 index 0000000..009f550 --- /dev/null +++ b/fixtures/mini_calc/TASK.md @@ -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`。工作区根目录就是本项目。 diff --git a/fixtures/mini_calc/calc.c b/fixtures/mini_calc/calc.c new file mode 100644 index 0000000..4f15948 --- /dev/null +++ b/fixtures/mini_calc/calc.c @@ -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 +#include +#include + +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 \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; +} diff --git a/fixtures/mini_calc/tests/smoke.sh b/fixtures/mini_calc/tests/smoke.sh new file mode 100755 index 0000000..f403ae5 --- /dev/null +++ b/fixtures/mini_calc/tests/smoke.sh @@ -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 diff --git a/fixtures/snake_cpp/Makefile b/fixtures/snake_cpp/Makefile new file mode 100644 index 0000000..1ddc5ab --- /dev/null +++ b/fixtures/snake_cpp/Makefile @@ -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 diff --git a/fixtures/snake_cpp/README.md b/fixtures/snake_cpp/README.md new file mode 100644 index 0000000..eb572e6 --- /dev/null +++ b/fixtures/snake_cpp/README.md @@ -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 diff --git a/fixtures/snake_cpp/TASK.md b/fixtures/snake_cpp/TASK.md new file mode 100644 index 0000000..efe5cad --- /dev/null +++ b/fixtures/snake_cpp/TASK.md @@ -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. diff --git a/fixtures/snake_cpp/snake.cpp b/fixtures/snake_cpp/snake.cpp new file mode 100644 index 0000000..5eb3d81 --- /dev/null +++ b/fixtures/snake_cpp/snake.cpp @@ -0,0 +1,224 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 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 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(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; +} diff --git a/include/cst_agent.h b/include/cst_agent.h new file mode 100644 index 0000000..1010516 --- /dev/null +++ b/include/cst_agent.h @@ -0,0 +1,174 @@ +#ifndef CST_AGENT_H +#define CST_AGENT_H + +#include "ctl.h" + +#include + +#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 */ diff --git a/include/ctl.h b/include/ctl.h new file mode 100644 index 0000000..8bd9b72 --- /dev/null +++ b/include/ctl.h @@ -0,0 +1,98 @@ +#ifndef CTL_H +#define CTL_H + +#include +#include + +#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 */ diff --git a/include/mm_tool.h b/include/mm_tool.h new file mode 100644 index 0000000..5990a7d --- /dev/null +++ b/include/mm_tool.h @@ -0,0 +1,51 @@ +#ifndef MM_TOOL_H +#define MM_TOOL_H + +#include "ctl.h" + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Streaming CST scanner for MiniMax native tool XML: + * + * + * + * README.md + * + * + * + * Also accepts the dialect seen in the wild: + * + * invoke read_file + * ./README.md + * + * + * Confirmation: nothing executes until . + */ + +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 diff --git a/include/os_compat.h b/include/os_compat.h new file mode 100644 index 0000000..2beb383 --- /dev/null +++ b/include/os_compat.h @@ -0,0 +1,68 @@ +#ifndef CST_OS_COMPAT_H +#define CST_OS_COMPAT_H + +#include +#include + +#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 → /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 */ diff --git a/scripts/bootstrap_linux.sh b/scripts/bootstrap_linux.sh new file mode 100755 index 0000000..7a8dc2e --- /dev/null +++ b/scripts/bootstrap_linux.sh @@ -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 ." diff --git a/scripts/bootstrap_termux.sh b/scripts/bootstrap_termux.sh new file mode 100755 index 0000000..203c82f --- /dev/null +++ b/scripts/bootstrap_termux.sh @@ -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" diff --git a/scripts/bootstrap_windows.sh b/scripts/bootstrap_windows.sh new file mode 100755 index 0000000..1e31209 --- /dev/null +++ b/scripts/bootstrap_windows.sh @@ -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" diff --git a/scripts/build_third_party_mingw.sh b/scripts/build_third_party_mingw.sh new file mode 100755 index 0000000..f51e05b --- /dev/null +++ b/scripts/build_third_party_mingw.sh @@ -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" diff --git a/scripts/e2e_mini_calc.sh b/scripts/e2e_mini_calc.sh new file mode 100755 index 0000000..de6b18d --- /dev/null +++ b/scripts/e2e_mini_calc.sh @@ -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 diff --git a/scripts/fetch_busybox_w32.sh b/scripts/fetch_busybox_w32.sh new file mode 100755 index 0000000..9b619cd --- /dev/null +++ b/scripts/fetch_busybox_w32.sh @@ -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." diff --git a/scripts/package_windows.sh b/scripts/package_windows.sh new file mode 100755 index 0000000..2ad57e1 --- /dev/null +++ b/scripts/package_windows.sh @@ -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" diff --git a/scripts/replay_starvation.sh b/scripts/replay_starvation.sh new file mode 100755 index 0000000..37f7684 --- /dev/null +++ b/scripts/replay_starvation.sh @@ -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." diff --git a/scripts/run_windows.bat b/scripts/run_windows.bat new file mode 100755 index 0000000..e0c8eff --- /dev/null +++ b/scripts/run_windows.bat @@ -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 diff --git a/src/agent_loop.c b/src/agent_loop.c new file mode 100644 index 0000000..fb24ff9 --- /dev/null +++ b/src/agent_loop.c @@ -0,0 +1,1182 @@ +#include "cst_agent.h" +#include "mm_tool.h" + +#include +#include +#include + +const char *cst_agent_system_prompt(void) { + return "You are CST-Agent, a coding assistant in a local workspace.\n" + "Reply in the user's language. Be concise.\n" + "\n" + "Allowed tools ONLY: read_file, run_shell, write_file, apply_patch.\n" + "Never invent other tool names (e.g. never call a tool named cmd).\n" + "\n" + "Prefer native function tools when the API offers them " + "(read_file path; run_shell cmd; write_file path+content; " + "apply_patch patch).\n" + "XML fallback format (exact):\n" + "\n" + "invoke read_file\n" + "agent/README.md\n" + "\n" + "\n" + "This workspace often has top-level dirs agent/ and SD/.\n" + "If README.md is missing at root, try agent/README.md or:\n" + "invoke run_shell with ls -la\n" + "\n" + "Workflow for summarize/explore:\n" + "1) Prefer few tool calls; stop when you can answer.\n" + "2) If a tool fails, try one different path — then answer.\n" + "3) After enough context, write the final answer in plain text only.\n" + "4) Never repeat a tool+args you already ran.\n" + "Workflow for fix/implement tasks:\n" + "1) Read the relevant files, then write/patch, then run tests via shell.\n" + "2) You may use several tool rounds; finish with a short Chinese summary.\n"; +} + +static int tool_allowed(const char *name) { + if (!name || !name[0]) + return 0; + return strcmp(name, "read_file") == 0 || strcmp(name, "run_shell") == 0 || + strcmp(name, "write_file") == 0 || strcmp(name, "apply_patch") == 0; +} + +static void tool_fingerprint(const ctl_tool_call_t *c, char *out, size_t n) { + out[0] = 0; + if (!c) + return; + size_t used = 0; + int k = snprintf(out + used, n - used, "%s", c->name); + if (k > 0) + used += (size_t)k; + for (int i = 0; i < c->nargs && used + 2 < n; i++) { + k = snprintf(out + used, n - used, "|%s=", c->args[i].key); + if (k > 0) + used += (size_t)k; + if (c->args[i].value) { + k = snprintf(out + used, n - used, "%s", c->args[i].value); + if (k > 0) + used += (size_t)k; + } + } +} + +#define FP_MAX 32 +#define FP_LEN 256 +/* Session/TUI keep full write diffs; LLM request truncates separately. */ +#define TOOL_OUT_CAP (48 * 1024) + +/* OpenCode max-steps style: after tools, ban further tools and demand text. */ +static const char *FINAL_NUDGE = + "[系统] CRITICAL — 工具结果已在上文。\n" + "1) 禁止再次调用任何工具(不要输出 )。\n" + "2) 必须用中文纯文本回答用户问题,基于上述工具输出做总结。\n" + "3) 不要只输出思考或标签。\n"; + +static const char *EMPTY_NUDGE = + "[系统] 上一轮没有产生可见回答。请立刻用中文直接回答;" + "若缺文件信息再调用 read_file 读 agent/README.md。"; + +static const char *CONTINUE_FIX_NUDGE = + "[系统] 还没有完成修改。请继续调用工具:用 write_file 或 apply_patch 改代码," + "再用 run_shell 执行 make test;通过后再用中文简短说明。"; + +static const char *RETRY_ANSWER_NUDGE = + "[系统] 你的上一轮回复为空或只有无效标签。" + "请立刻用中文写出完整总结,禁止工具调用。"; + +static int text_is_meaningful(const char *s, size_t n) { + size_t letters = 0; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s[i]; + if (c > 0x20) + letters++; + } + /* ~几字中文即可;过严会导致工具后短答被当成空。 */ + return letters >= 4; +} + +/* Strip MiniMax tool markup leftovers from assistant text. */ +static void strip_tool_markup(char *s, size_t *len_io) { + if (!s || !len_io) + return; + static const char *tags[] = { + "", + "", + "", + "", + NULL, + }; + for (int t = 0; tags[t]; t++) { + const char *tag = tags[t]; + size_t olen = strlen(tag); + char *p = s; + while ((p = strstr(p, tag)) != NULL) { + size_t rest = *len_io - (size_t)(p - s) - olen; + memmove(p, p + olen, rest + 1); + *len_io -= olen; + } + } +} + + +typedef struct { + char *text_acc; + size_t text_len; + size_t text_cap; + ctl_tool_call_t **calls; + int ncalls; + int ncalls_cap; + int n_native; /* calls added via on_native_tool */ + int parse_error; + char err[256]; + const cst_agent_hooks_t *hooks; +} sink_t; + +static ctl_tool_call_t *deep_copy_call(const ctl_tool_call_t *src) { + ctl_tool_call_t *d = calloc(1, sizeof(*d)); + if (!d) + return NULL; + memcpy(d->id, src->id, sizeof(d->id)); + memcpy(d->name, src->name, sizeof(d->name)); + d->nargs = src->nargs; + for (int i = 0; i < src->nargs; i++) { + memcpy(d->args[i].key, src->args[i].key, sizeof(d->args[i].key)); + d->args[i].value_len = src->args[i].value_len; + d->args[i].value = malloc(src->args[i].value_len + 1); + if (!d->args[i].value) { + for (int j = 0; j < i; j++) + free(d->args[j].value); + free(d); + return NULL; + } + if (src->args[i].value_len) + memcpy(d->args[i].value, src->args[i].value, src->args[i].value_len); + d->args[i].value[src->args[i].value_len] = 0; + } + return d; +} + +static void free_call(ctl_tool_call_t *c) { + if (!c) + return; + for (int i = 0; i < c->nargs; i++) + free(c->args[i].value); + free(c); +} + +static void sink_free(sink_t *s) { + free(s->text_acc); + for (int i = 0; i < s->ncalls; i++) + free_call(s->calls[i]); + free(s->calls); + memset(s, 0, sizeof(*s)); +} + +static void hook_status(const sink_t *s, const char *msg) { + if (s->hooks && s->hooks->on_status) + s->hooks->on_status(msg, s->hooks->userdata); +} + +static void on_ctl(const ctl_event_t *ev, void *ud) { + sink_t *s = ud; + if (ev->type == CTL_EV_TEXT && ev->text && ev->text_len) { + if (s->text_len + ev->text_len + 1 > s->text_cap) { + size_t nc = s->text_cap ? s->text_cap * 2 : 4096; + while (nc < s->text_len + ev->text_len + 1) + nc *= 2; + char *nb = realloc(s->text_acc, nc); + if (!nb) + return; + s->text_acc = nb; + s->text_cap = nc; + } + memcpy(s->text_acc + s->text_len, ev->text, ev->text_len); + s->text_len += ev->text_len; + s->text_acc[s->text_len] = 0; + if (s->hooks && s->hooks->on_assistant_delta) + s->hooks->on_assistant_delta(ev->text, ev->text_len, s->hooks->userdata); + } else if (ev->type == CTL_EV_CALL_OPEN && ev->call) { + if (!tool_allowed(ev->call->name)) + return; + if (s->hooks && s->hooks->on_tool_event) + s->hooks->on_tool_event("open", ev->call, NULL, s->hooks->userdata); + } else if (ev->type == CTL_EV_ARG && ev->call) { + if (!tool_allowed(ev->call->name)) + return; + if (s->hooks && s->hooks->on_tool_event) + s->hooks->on_tool_event("arg", ev->call, NULL, s->hooks->userdata); + } else if (ev->type == CTL_EV_CALL_READY && ev->call) { + /* Prefer native tool_calls; skip XML duplicates when native already fired. */ + if (s->n_native > 0) + return; + if (!tool_allowed(ev->call->name)) { + /* Ignore unknown tools (e.g. spurious "cmd") instead of looping. */ + if (s->hooks && s->hooks->on_status) { + char msg[128]; + snprintf(msg, sizeof(msg), "ignored unknown tool: %s", ev->call->name); + s->hooks->on_status(msg, s->hooks->userdata); + } + return; + } + if (s->hooks && s->hooks->on_tool_event) + s->hooks->on_tool_event("ready", ev->call, NULL, s->hooks->userdata); + ctl_tool_call_t *cp = deep_copy_call(ev->call); + if (!cp) + return; + if (s->ncalls >= s->ncalls_cap) { + int nc = s->ncalls_cap ? s->ncalls_cap * 2 : 4; + ctl_tool_call_t **narr = + realloc(s->calls, (size_t)nc * sizeof(ctl_tool_call_t *)); + if (!narr) { + free_call(cp); + return; + } + s->calls = narr; + s->ncalls_cap = nc; + } + s->calls[s->ncalls++] = cp; + } else if (ev->type == CTL_EV_ERROR) { + s->parse_error = 1; + snprintf(s->err, sizeof(s->err), "%s", ev->error ? ev->error : "ctl error"); + } +} + +/* Strip MiniMax ...; route visible text through MiniMax tool + * scanner then CTL. */ +typedef struct { + ctl_parser_t *parser; + mm_scan_t *mm; + sink_t *sink; + const cst_agent_hooks_t *hooks; + int in_think; + char tagbuf[16]; + size_t taglen; +} delta_ctx_t; + +static void on_mm(const ctl_event_t *ev, void *ud) { + delta_ctx_t *d = ud; + if (ev->type == CTL_EV_TEXT && ev->text && ev->text_len) { + ctl_parser_feed(d->parser, ev->text, ev->text_len); + } else if (ev->type == CTL_EV_CALL_OPEN || ev->type == CTL_EV_CALL_READY || + ev->type == CTL_EV_ARG || ev->type == CTL_EV_ERROR) { + on_ctl(ev, d->sink); + } +} + +static void feed_visible(delta_ctx_t *d, const char *data, size_t len) { + if (!len) + return; + if (d->mm) + mm_scan_feed(d->mm, data, len); + else + ctl_parser_feed(d->parser, data, len); +} + +static int sink_push_call(sink_t *s, ctl_tool_call_t *cp) { + if (!s || !cp) + return -1; + if (s->ncalls >= s->ncalls_cap) { + int nc = s->ncalls_cap ? s->ncalls_cap * 2 : 4; + ctl_tool_call_t **narr = + realloc(s->calls, (size_t)nc * sizeof(ctl_tool_call_t *)); + if (!narr) + return -1; + s->calls = narr; + s->ncalls_cap = nc; + } + s->calls[s->ncalls++] = cp; + return 0; +} + +static void on_native_tool(const char *id, const char *name, + const char *args_json, void *ud) { + delta_ctx_t *d = ud; + sink_t *s = d ? d->sink : NULL; + if (!s || !name) + return; + if (!tool_allowed(name)) { + if (s->hooks && s->hooks->on_status) { + char msg[128]; + snprintf(msg, sizeof(msg), "ignored unknown tool: %s", name); + s->hooks->on_status(msg, s->hooks->userdata); + } + return; + } + ctl_tool_call_t tmp = {0}; + if (cst_tool_call_from_native(&tmp, id, name, args_json) != 0) + return; + if (!tmp.id[0]) + snprintf(tmp.id, sizeof(tmp.id), "native_%d", s->n_native + 1); + if (s->hooks && s->hooks->on_tool_event) + s->hooks->on_tool_event("ready", &tmp, NULL, s->hooks->userdata); + ctl_tool_call_t *cp = deep_copy_call(&tmp); + for (int i = 0; i < tmp.nargs; i++) + free(tmp.args[i].value); + if (!cp) + return; + if (sink_push_call(s, cp) != 0) { + free_call(cp); + return; + } + s->n_native++; +} + +static void on_delta(const char *data, size_t len, void *ud) { + delta_ctx_t *d = ud; + for (size_t i = 0; i < len; i++) { + char c = data[i]; + if (!d->in_think) { + if (c == '<') { + d->tagbuf[0] = '<'; + d->taglen = 1; + continue; + } + if (d->taglen > 0) { + if (d->taglen + 1 < sizeof(d->tagbuf)) + d->tagbuf[d->taglen++] = c; + else { + feed_visible(d, d->tagbuf, d->taglen); + d->taglen = 0; + feed_visible(d, &c, 1); + continue; + } + d->tagbuf[d->taglen] = 0; + if (d->taglen == 7 && strncmp(d->tagbuf, "", 7) == 0) { + d->in_think = 1; + d->taglen = 0; + continue; + } + /* not a think open — if cannot be prefix of , flush */ + if (strncmp("", d->tagbuf, d->taglen) != 0) { + feed_visible(d, d->tagbuf, d->taglen); + d->taglen = 0; + } + continue; + } + feed_visible(d, &c, 1); + } else { + /* in think: look for ; do NOT feed mm/ctl (avoids UTF-8 + * keep-buffer contamination into the visible answer). */ + if (c == '<') { + d->tagbuf[0] = '<'; + d->taglen = 1; + continue; + } + if (d->taglen > 0) { + if (d->taglen + 1 < sizeof(d->tagbuf)) + d->tagbuf[d->taglen++] = c; + else { + if (d->hooks && d->hooks->on_think_delta) + d->hooks->on_think_delta(d->tagbuf, d->taglen, d->hooks->userdata); + d->taglen = 0; + if (d->hooks && d->hooks->on_think_delta) + d->hooks->on_think_delta(&c, 1, d->hooks->userdata); + continue; + } + d->tagbuf[d->taglen] = 0; + if (d->taglen == 8 && strncmp(d->tagbuf, "", 8) == 0) { + d->in_think = 0; + d->taglen = 0; + continue; + } + if (strncmp("", d->tagbuf, d->taglen) != 0) { + if (d->hooks && d->hooks->on_think_delta) + d->hooks->on_think_delta(d->tagbuf, d->taglen, d->hooks->userdata); + d->taglen = 0; + } + continue; + } + if (d->hooks && d->hooks->on_think_delta) + d->hooks->on_think_delta(&c, 1, d->hooks->userdata); + } + } +} + +struct cst_agent { + cst_agent_config_t cfg; + cst_session_t *session; + volatile int cancel; +}; + +cst_agent_t *cst_agent_create(const cst_agent_config_t *cfg) { + if (!cfg) + return NULL; + cst_agent_t *a = calloc(1, sizeof(*a)); + if (!a) + return NULL; + a->cfg = *cfg; + a->session = cst_session_open(cfg->session_path[0] ? cfg->session_path + : "session.jsonl"); + if (!a->session) { + free(a); + return NULL; + } + /* Only seed system once per process session object (file may already have + * history from prior runs — still append system for this in-memory view). */ + if (cst_session_count(a->session) == 0) { + cst_message_t sys = {0}; + sys.role = CST_ROLE_SYSTEM; + sys.content = (char *)cst_agent_system_prompt(); + cst_session_append(a->session, &sys); + } + return a; +} + +void cst_agent_destroy(cst_agent_t *a) { + if (!a) + return; + cst_session_close(a->session); + free(a); +} + +cst_session_t *cst_agent_session(cst_agent_t *a) { + return a ? a->session : NULL; +} + +const cst_agent_config_t *cst_agent_config(const cst_agent_t *a) { + return a ? &a->cfg : NULL; +} + +cst_agent_config_t *cst_agent_config_mut(cst_agent_t *a) { + return a ? &a->cfg : NULL; +} + +void cst_agent_request_cancel(cst_agent_t *a) { + if (a) + a->cancel = 1; +} + +int cst_agent_run_turn(cst_agent_t *a, const char *user_text, char *err, + size_t err_sz) { + return cst_agent_run_turn_ex(a, user_text, NULL, err, 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) { + if (err && err_sz) + err[0] = 0; + if (!a || !user_text) + return -1; + + a->cancel = 0; + + cst_message_t um = {0}; + um.role = CST_ROLE_USER; + um.content = (char *)user_text; + if (cst_session_append(a->session, &um) != 0) { + if (err) + snprintf(err, err_sz, "session append failed"); + return -1; + } + + const int max_iters = 6; + char seen_fp[FP_MAX][FP_LEN]; + int nseen = 0; + int tool_rounds = 0; + int force_answer = 0; + int empty_retries = 0; + int answer_retries = 0; + int tools_ran = 0; /* OpenCode: after tools, must continue until text */ + + for (int iter = 0; iter < max_iters; iter++) { + if (force_answer) { + cst_message_t nudge = {0}; + nudge.role = CST_ROLE_USER; + nudge.content = + (char *)(answer_retries > 0 ? RETRY_ANSWER_NUDGE : FINAL_NUDGE); + cst_session_append(a->session, &nudge); + } + + sink_t sink = {0}; + sink.hooks = hooks; + ctl_parser_t *parser = ctl_parser_create(on_ctl, &sink); + if (!parser) { + if (err) + snprintf(err, err_sz, "parser create failed"); + return -1; + } + delta_ctx_t dctx = {0}; + dctx.parser = parser; + dctx.sink = &sink; + dctx.hooks = hooks; + dctx.mm = mm_scan_create(on_mm, &dctx); + if (!dctx.mm) { + ctl_parser_destroy(parser); + if (err) + snprintf(err, err_sz, "mm scanner create failed"); + return -1; + } + + int n = cst_session_count(a->session); + /* system + last CST_AGENT_CTX_WINDOW non-system messages */ + int nonsys = 0; + int have_sys = 0; + for (int i = 0; i < n; i++) { + const cst_message_t *m = cst_session_get(a->session, i); + if (m->role == CST_ROLE_SYSTEM) + have_sys = 1; + else + nonsys++; + } + int skip_nonsys = 0; + if (nonsys > CST_AGENT_CTX_WINDOW) + skip_nonsys = nonsys - CST_AGENT_CTX_WINDOW; + int extra = have_sys ? 0 : 1; + cst_message_t *msgs = calloc((size_t)(n + extra), sizeof(cst_message_t)); + if (!msgs) { + mm_scan_destroy(dctx.mm); + ctl_parser_destroy(parser); + return -1; + } + int mi = 0; + if (!have_sys) { + msgs[mi].role = CST_ROLE_SYSTEM; + msgs[mi].content = (char *)cst_agent_system_prompt(); + mi++; + } + int skipped = 0; + for (int i = 0; i < n; i++) { + const cst_message_t *m = cst_session_get(a->session, i); + if (m->role == CST_ROLE_SYSTEM) { + msgs[mi++] = *m; + continue; + } + if (skipped < skip_nonsys) { + skipped++; + continue; + } + msgs[mi++] = *m; + } + /* Drop leading orphan tool results (no preceding assistant tool_calls). */ + { + int sys_end = 0; + while (sys_end < mi && msgs[sys_end].role == CST_ROLE_SYSTEM) + sys_end++; + while (sys_end < mi && msgs[sys_end].role == CST_ROLE_TOOL) { + for (int j = sys_end; j + 1 < mi; j++) + msgs[j] = msgs[j + 1]; + mi--; + } + } + + hook_status(&sink, force_answer ? "final answer pass..." : "calling model..."); + + cst_llm_request_t req = {0}; + req.api_base = a->cfg.api_base; + req.api_key = a->cfg.api_key; + req.model = a->cfg.model; + req.msgs = msgs; + req.nmsgs = mi; + req.on_delta = on_delta; + req.on_native_tool = on_native_tool; + req.userdata = &dctx; + req.enable_tools = 1; /* keep tools[] schema even on force-answer */ + req.tool_choice_none = force_answer; + req.cancel_flag = &a->cancel; + + char lerr[512]; + int lrc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); + free(msgs); + if (lrc != 0) { + mm_scan_destroy(dctx.mm); + ctl_parser_destroy(parser); + sink_free(&sink); + if (err) + snprintf(err, err_sz, "llm: %s", lerr); + return -1; + } + + if (!dctx.in_think && dctx.taglen) + feed_visible(&dctx, dctx.tagbuf, dctx.taglen); + + mm_scan_finish(dctx.mm); + mm_scan_destroy(dctx.mm); + dctx.mm = NULL; + + ctl_status_t fr = ctl_parser_finish(parser); + ctl_parser_destroy(parser); + + /* Meaningful assistant text after orphan-tag cleanup. + * If there are tool calls, defer append so we can attach tool_calls_json. */ + int has_text = 0; + char *text_body = NULL; + if (sink.text_acc && sink.text_len) { + strip_tool_markup(sink.text_acc, &sink.text_len); + if (text_is_meaningful(sink.text_acc, sink.text_len)) { + size_t i = 0; + while (i < sink.text_len && + (sink.text_acc[i] == ' ' || sink.text_acc[i] == '\n' || + sink.text_acc[i] == '\r' || sink.text_acc[i] == '\t')) + i++; + if (i < sink.text_len) { + text_body = sink.text_acc + i; + has_text = 1; + } + } + } + if (has_text && sink.ncalls == 0) { + cst_message_t am = {0}; + am.role = CST_ROLE_ASSISTANT; + am.content = text_body; + cst_session_append(a->session, &am); + } + + if ((sink.parse_error || fr != CTL_OK) && sink.ncalls == 0) { + if (has_text) { + hook_status(&sink, "done (text only)"); + sink_free(&sink); + return 0; + } + /* Incomplete CTL but we may still need an answer after tools. */ + if (!tools_ran) { + if (err) + snprintf(err, err_sz, "ctl: %s", + sink.err[0] ? sink.err : "incomplete"); + sink_free(&sink); + return -1; + } + } + + /* + * OpenCode: finish=stop with no pending tools ends the loop. + * Gap we fill: empty text AFTER tools must NOT end — force text. + */ + if (force_answer) { + /* Drop any further tool calls on the answer pass. */ + sink.ncalls = 0; + if (has_text) { + hook_status(&sink, "done"); + sink_free(&sink); + return 0; + } + sink_free(&sink); + if (answer_retries < 2) { + answer_retries++; + if (hooks && hooks->on_status) + hooks->on_status("empty after tools — forcing answer...", + hooks->userdata); + continue; + } + /* Last resort: surface last tool output so the turn isn't blank. */ + if (tools_ran) { + int n = cst_session_count(a->session); + for (int i = n - 1; i >= 0; i--) { + const cst_message_t *m = cst_session_get(a->session, i); + if (!m || m->role != CST_ROLE_TOOL || !m->content) + continue; + char *syn = malloc(strlen(m->content) + 128); + if (syn) { + snprintf(syn, strlen(m->content) + 128, + "(模型未返回正文,以下为最近工具输出摘要)\n%.2000s", + m->content); + cst_message_t am = {0}; + am.role = CST_ROLE_ASSISTANT; + am.content = syn; + cst_session_append(a->session, &am); + free(syn); + } + if (hooks && hooks->on_status) + hooks->on_status("empty answer — showing tool output fallback", + hooks->userdata); + return 0; + } + } + if (err) + snprintf(err, err_sz, "empty model response"); + return -1; + } + + if (sink.ncalls == 0) { + if (has_text) { + hook_status(&sink, "done"); + sink_free(&sink); + return 0; + } + sink_free(&sink); + /* Empty after tools: if rounds remain, nudge to keep fixing — do not + * ban tools yet (coding tasks need write/test after read). */ + if (tools_ran && tool_rounds < 4) { + cst_message_t nudge = {0}; + nudge.role = CST_ROLE_USER; + nudge.content = (char *)CONTINUE_FIX_NUDGE; + cst_session_append(a->session, &nudge); + if (hooks && hooks->on_status) + hooks->on_status("continuing after tools…", hooks->userdata); + continue; + } + if (tools_ran) { + force_answer = 1; + if (hooks && hooks->on_status) + hooks->on_status("tools done — requesting final answer...", + hooks->userdata); + continue; + } + if (empty_retries < 2) { + empty_retries++; + cst_message_t nudge = {0}; + nudge.role = CST_ROLE_USER; + nudge.content = (char *)EMPTY_NUDGE; + cst_session_append(a->session, &nudge); + if (hooks && hooks->on_status) + hooks->on_status("empty response — retrying...", hooks->userdata); + continue; + } + if (err) + snprintf(err, err_sz, "empty model response"); + return -1; + } + + /* Have tool calls — execute (OpenCode continues while tools pending). */ + int executed = 0; + + /* Ensure every call has a non-empty id (API requires tool_call_id match). */ + for (int i = 0; i < sink.ncalls; i++) { + ctl_tool_call_t *c = sink.calls[i]; + if (!c->id[0]) + snprintf(c->id, sizeof(c->id), "call_%d", i + 1); + } + + /* MiniMax/OpenAI: assistant tool_calls must precede role=tool results. */ + { + size_t cap = 1024; + char *tcj = malloc(cap); + if (tcj) { + size_t len = 0; + tcj[len++] = '['; + tcj[len] = 0; + int first = 1; + for (int i = 0; i < sink.ncalls; i++) { + ctl_tool_call_t *c = sink.calls[i]; + if (!tool_allowed(c->name)) + continue; + /* Rebuild arguments object from args */ + char argsbuf[4096]; + size_t al = 0; + argsbuf[al++] = '{'; + for (int ai = 0; ai < c->nargs; ai++) { + if (ai) + argsbuf[al++] = ','; + al += (size_t)snprintf(argsbuf + al, sizeof(argsbuf) - al, + "\"%s\":\"", c->args[ai].key); + const char *v = c->args[ai].value ? c->args[ai].value : ""; + for (; *v && al + 8 < sizeof(argsbuf); v++) { + if (*v == '"' || *v == '\\') { + argsbuf[al++] = '\\'; + argsbuf[al++] = *v; + } else if (*v == '\n') { + argsbuf[al++] = '\\'; + argsbuf[al++] = 'n'; + } else { + argsbuf[al++] = *v; + } + } + argsbuf[al++] = '"'; + } + argsbuf[al++] = '}'; + argsbuf[al] = 0; + + char piece[4600]; + int pn = snprintf( + piece, sizeof(piece), + "%s{\"id\":\"%s\",\"type\":\"function\",\"function\":{\"name\":\"%s\"," + "\"arguments\":", + first ? "" : ",", c->id, c->name); + first = 0; + size_t need = len + (size_t)pn + al + 8; + if (need > cap) { + while (cap < need) + cap *= 2; + char *nb = realloc(tcj, cap); + if (!nb) + break; + tcj = nb; + } + memcpy(tcj + len, piece, (size_t)pn); + len += (size_t)pn; + /* arguments value must be a JSON string containing the object */ + tcj[len++] = '"'; + for (size_t k = 0; k < al; k++) { + char ch = argsbuf[k]; + if (ch == '"' || ch == '\\') { + tcj[len++] = '\\'; + tcj[len++] = ch; + } else if (ch == '\n') { + tcj[len++] = '\\'; + tcj[len++] = 'n'; + } else { + tcj[len++] = ch; + } + } + tcj[len++] = '"'; + tcj[len++] = '}'; + tcj[len++] = '}'; + tcj[len] = 0; + } + if (!first) { + if (len + 2 > cap) { + char *nb = realloc(tcj, len + 2); + if (nb) { + tcj = nb; + cap = len + 2; + } + } + tcj[len++] = ']'; + tcj[len] = 0; + cst_message_t am = {0}; + am.role = CST_ROLE_ASSISTANT; + am.content = text_body ? text_body : (char *)""; + am.tool_calls_json = tcj; + cst_session_append(a->session, &am); + } + free(tcj); + } + } + + for (int i = 0; i < sink.ncalls; i++) { + ctl_tool_call_t *c = sink.calls[i]; + if (!tool_allowed(c->name)) + continue; + + char fp[FP_LEN]; + tool_fingerprint(c, fp, sizeof(fp)); + int dup = 0; + for (int j = 0; j < nseen; j++) { + if (strcmp(seen_fp[j], fp) == 0) { + dup = 1; + break; + } + } + if (dup) { + if (hooks && hooks->on_status) + hooks->on_status("skip duplicate tool", hooks->userdata); + /* Still pair a tool result so the API accepts the prior tool_calls. */ + cst_message_t tm = {0}; + tm.role = CST_ROLE_TOOL; + snprintf(tm.tool_call_id, sizeof(tm.tool_call_id), "%s", c->id); + snprintf(tm.tool_name, sizeof(tm.tool_name), "%s", c->name); + tm.content = (char *)"skipped duplicate tool call"; + cst_session_append(a->session, &tm); + executed++; + continue; + } + if (nseen < FP_MAX) + snprintf(seen_fp[nseen++], FP_LEN, "%s", fp); + + /* Permission gate for shell / write tools */ + int is_shell = strcmp(c->name, "run_shell") == 0; + int is_write = strcmp(c->name, "write_file") == 0 || + strcmp(c->name, "apply_patch") == 0; + if (is_shell || is_write) { + char detail[256]; + detail[0] = 0; + for (int ai = 0; ai < c->nargs && !detail[0]; ai++) { + if (c->args[ai].value) { + snprintf(detail, sizeof(detail), "%s=%s", c->args[ai].key, + c->args[ai].value); + } + } + int allow = 1; + int need_confirm = + is_shell ? a->cfg.confirm_shell : a->cfg.confirm_write; + if (hooks && hooks->on_permission) { + allow = hooks->on_permission(c->name, detail[0] ? detail : c->name, + hooks->userdata); + } else if (need_confirm && !a->cfg.auto_approve) { + /* No interactive hook: deny when confirmation required. */ + allow = 0; + } + if (!allow) { + cst_message_t tm = {0}; + tm.role = CST_ROLE_TOOL; + snprintf(tm.tool_call_id, sizeof(tm.tool_call_id), "%s", c->id); + snprintf(tm.tool_name, sizeof(tm.tool_name), "%s", c->name); + tm.content = (char *)"denied by user"; + cst_session_append(a->session, &tm); + if (hooks && hooks->on_tool_event) + hooks->on_tool_event("denied", c, detail, hooks->userdata); + executed++; + continue; + } + } + + if (hooks && hooks->on_tool_event) + hooks->on_tool_event("run", c, NULL, hooks->userdata); + cst_tool_result_t tr = {0}; + cst_tool_dispatch(a->cfg.workspace_root, c, &tr); + char detail[128]; + snprintf(detail, sizeof(detail), "exit=%d bytes=%zu", tr.exit_code, + tr.out_len); + + const char *body = tr.out ? tr.out : ""; + size_t blen = tr.out_len; + int truncated = 0; + if (blen > TOOL_OUT_CAP) { + blen = TOOL_OUT_CAP; + truncated = 1; + } + char hdr[256]; + snprintf(hdr, sizeof(hdr), "[tool %s id=%s exit=%d]", c->name, c->id, + tr.exit_code); + size_t clen = strlen(hdr) + 2 + blen + 32; + char *combined = malloc(clen); + if (combined) { + if (truncated) + snprintf(combined, clen, "%s\n%.*s\n...(truncated)", hdr, (int)blen, + body); + else + snprintf(combined, clen, "%s\n%.*s", hdr, (int)blen, body); + cst_message_t tm = {0}; + tm.role = CST_ROLE_TOOL; + snprintf(tm.tool_call_id, sizeof(tm.tool_call_id), "%s", c->id); + snprintf(tm.tool_name, sizeof(tm.tool_name), "%s", c->name); + tm.content = combined; + /* Append before done hook so TUI can attach output immediately. */ + cst_session_append(a->session, &tm); + free(combined); + } + if (hooks && hooks->on_tool_event) + hooks->on_tool_event("done", c, detail, hooks->userdata); + cst_tool_result_free(&tr); + executed++; + } + + sink_free(&sink); + + tool_rounds++; + if (executed > 0) + tools_ran = 1; + + /* Max tool rounds before forcing a text-only answer (coding needs >1). */ + if (tool_rounds >= 4) + force_answer = 1; + if (hooks && hooks->on_status) { + if (force_answer) + hooks->on_status("tools done — requesting final answer...", + hooks->userdata); + else + hooks->on_status("tool round done...", hooks->userdata); + } + } + if (err) + snprintf(err, err_sz, "max tool iterations"); + return -1; +} + +static int agent_reopen_session(cst_agent_t *a, const char *path) { + cst_session_t *ns = cst_session_open(path); + if (!ns) + return -1; + cst_session_close(a->session); + a->session = ns; + snprintf(a->cfg.session_path, sizeof(a->cfg.session_path), "%s", path); + if (cst_session_count(a->session) == 0) { + cst_message_t sys = {0}; + sys.role = CST_ROLE_SYSTEM; + sys.content = (char *)cst_agent_system_prompt(); + cst_session_append(a->session, &sys); + } + return 0; +} + +int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz) { + if (!a) + return -1; + if (cst_agent_store_ensure(a->cfg.workspace_root) != 0) + return -1; + char id[128]; + if (cst_agent_store_new_id(id, sizeof(id)) != 0) + return -1; + char path[4096]; + if (cst_agent_store_path_for_id(a->cfg.workspace_root, id, path, + sizeof(path)) != 0) + return -1; + if (agent_reopen_session(a, path) != 0) + return -1; + cst_agent_store_set_current(a->cfg.workspace_root, id); + if (id_out && id_sz) + snprintf(id_out, id_sz, "%s", id); + return 0; +} + +int cst_agent_switch_session(cst_agent_t *a, const char *id) { + if (!a || !id || !id[0]) + return -1; + char path[4096]; + if (cst_agent_store_path_for_id(a->cfg.workspace_root, id, path, + sizeof(path)) != 0) + return -1; + if (agent_reopen_session(a, path) != 0) + return -1; + /* Strip .jsonl for CURRENT id */ + char bare[128]; + snprintf(bare, sizeof(bare), "%s", id); + size_t n = strlen(bare); + if (n > 6 && strcmp(bare + n - 6, ".jsonl") == 0) + bare[n - 6] = 0; + cst_agent_store_set_current(a->cfg.workspace_root, bare); + return 0; +} + +typedef struct { + char *buf; + size_t len; + size_t cap; +} acc_t; + +static void acc_feed(const char *data, size_t len, void *ud) { + acc_t *a = ud; + if (!data || !len) + return; + if (a->len + len + 1 > a->cap) { + size_t nc = a->cap ? a->cap * 2 : 4096; + while (nc < a->len + len + 1) + nc *= 2; + char *nb = realloc(a->buf, nc); + if (!nb) + return; + a->buf = nb; + a->cap = nc; + } + memcpy(a->buf + a->len, data, len); + a->len += len; + a->buf[a->len] = 0; +} + +int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz) { + if (err && err_sz) + err[0] = 0; + if (!a || !a->session) + return -1; + + int n = cst_session_count(a->session); + if (n <= 1) { + if (err) + snprintf(err, err_sz, "nothing to compact"); + return -1; + } + + /* Build a compact request: system + one user blob of prior turns. */ + size_t blob_cap = 8192, blob_len = 0; + char *blob = malloc(blob_cap); + if (!blob) + return -1; + const char *hdr = + "请将下列会话压缩为简洁中文摘要,保留:目标、关键路径、已做决策、" + "未完成事项。不要调用工具,直接输出摘要正文。\n\n---\n"; + size_t hlen = strlen(hdr); + memcpy(blob, hdr, hlen); + blob_len = hlen; + blob[blob_len] = 0; + + for (int i = 0; i < n; i++) { + const cst_message_t *m = cst_session_get(a->session, i); + if (!m || m->role == CST_ROLE_SYSTEM) + continue; + const char *role = m->role == CST_ROLE_USER ? "user" + : m->role == CST_ROLE_ASSISTANT ? "assistant" + : "tool"; + const char *c = m->content ? m->content : ""; + size_t cl = strlen(c); + if (cl > 2000) + cl = 2000; + char line[128]; + int ln = snprintf(line, sizeof(line), "\n[%s]\n", role); + while (blob_len + (size_t)ln + cl + 8 > blob_cap) { + blob_cap *= 2; + char *nb = realloc(blob, blob_cap); + if (!nb) { + free(blob); + return -1; + } + blob = nb; + } + memcpy(blob + blob_len, line, (size_t)ln); + blob_len += (size_t)ln; + memcpy(blob + blob_len, c, cl); + blob_len += cl; + blob[blob_len] = 0; + if (blob_len > 120000) + break; + } + + cst_message_t reqs[2]; + memset(reqs, 0, sizeof(reqs)); + reqs[0].role = CST_ROLE_SYSTEM; + reqs[0].content = (char *)"You summarize coding-agent sessions. No tools."; + reqs[1].role = CST_ROLE_USER; + reqs[1].content = blob; + + acc_t acc = {0}; + cst_llm_request_t req = {0}; + req.api_base = a->cfg.api_base; + req.api_key = a->cfg.api_key; + req.model = a->cfg.model; + req.msgs = reqs; + req.nmsgs = 2; + req.on_delta = acc_feed; + req.userdata = &acc; + + char lerr[512]; + int rc = cst_llm_chat_stream(&req, lerr, sizeof(lerr)); + free(blob); + if (rc != 0 || !acc.buf || acc.len == 0) { + free(acc.buf); + if (err) + snprintf(err, err_sz, "compact llm: %s", lerr[0] ? lerr : "empty"); + return -1; + } + + /* Strip ... blocks from summary. */ + { + char *src = acc.buf; + char *dst = acc.buf; + while (*src) { + if (strncmp(src, "", 7) == 0) { + char *end = strstr(src, ""); + if (end) { + src = end + 8; + continue; + } + } + *dst++ = *src++; + } + *dst = 0; + acc.len = (size_t)(dst - acc.buf); + while (acc.len > 0 && + (acc.buf[acc.len - 1] == '\n' || acc.buf[acc.len - 1] == ' ')) + acc.buf[--acc.len] = 0; + } + if (acc.len < 8) { + free(acc.buf); + if (err) + snprintf(err, err_sz, "compact produced empty summary"); + return -1; + } + + cst_message_t keep[2]; + memset(keep, 0, sizeof(keep)); + keep[0].role = CST_ROLE_SYSTEM; + keep[0].content = (char *)cst_agent_system_prompt(); + keep[1].role = CST_ROLE_USER; + size_t slen = acc.len + 64; + char *summary = malloc(slen); + if (!summary) { + free(acc.buf); + return -1; + } + snprintf(summary, slen, "[会话已压缩 /compact]\n%s", acc.buf); + free(acc.buf); + keep[1].content = summary; + + if (cst_session_rewrite(a->session, keep, 2) != 0) { + free(summary); + if (err) + snprintf(err, err_sz, "rewrite session failed"); + return -1; + } + free(summary); + return 0; +} diff --git a/src/agent_store.c b/src/agent_store.c new file mode 100644 index 0000000..852f0a0 --- /dev/null +++ b/src/agent_store.c @@ -0,0 +1,189 @@ +#define _DEFAULT_SOURCE + +#include "cst_agent.h" +#include "os_compat.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#define OS_ACCESS _access +#define OS_F_OK 0 +#else +#include +#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; +} diff --git a/src/creds.c b/src/creds.c new file mode 100644 index 0000000..241a12f --- /dev/null +++ b/src/creds.c @@ -0,0 +1,140 @@ +#define _DEFAULT_SOURCE + +#include "cst_agent.h" +#include "os_compat.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#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; +} diff --git a/src/ctl_parse.c b/src/ctl_parse.c new file mode 100644 index 0000000..4bf6262 --- /dev/null +++ b/src/ctl_parse.c @@ -0,0 +1,419 @@ +#include "ctl.h" + +#include +#include +#include +#include + +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 */ + 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 → length-prefixed body follows + * arg → 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; +} diff --git a/src/json_baseline.c b/src/json_baseline.c new file mode 100644 index 0000000..3dd305e --- /dev/null +++ b/src/json_baseline.c @@ -0,0 +1,145 @@ +#include "cst_agent.h" + +#include +#include + +/* 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; +} diff --git a/src/llm_openai.c b/src/llm_openai.c new file mode 100644 index 0000000..06c3348 --- /dev/null +++ b/src/llm_openai.c @@ -0,0 +1,786 @@ +#include "cst_agent.h" + +#include +#include +#include +#include +#include + +#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 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, "", 8); + a->in_reason = 0; + } + flush_native_tools(a); + return; + } + + char out[8192]; + size_t olen = 0; + + /* MiniMax reasoning_split: route reasoning_content through 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, "", 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, "", 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, "", 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; +} diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..826d17b --- /dev/null +++ b/src/main.c @@ -0,0 +1,283 @@ +#include "cst_agent.h" +#include "os_compat.h" + +#include +#include +#include +#include +#include +#ifdef _WIN32 +#include +#else +#include +#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: /.agent/sessions/.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 /.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; +} diff --git a/src/mm_tool.c b/src/mm_tool.c new file mode 100644 index 0000000..e9af8db --- /dev/null +++ b/src/mm_tool.c @@ -0,0 +1,410 @@ +#include "mm_tool.h" + +#include +#include +#include + +#define MM_OPEN "" +#define MM_CLOSE "" +#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 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, "'); + 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, ""); + 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, "'); + 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 ? "" : ""; + 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, "", 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 ... until next invoke or end */ + while (*p) { + while (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t') + p++; + if (strncmp(p, "invoke ", 7) == 0 || strncmp(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 ? "" : ""; + 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; +} diff --git a/src/models.c b/src/models.c new file mode 100644 index 0000000..bd0b482 --- /dev/null +++ b/src/models.c @@ -0,0 +1,67 @@ +#include "cst_agent.h" + +#include +#include +#include + +#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; +} diff --git a/src/os_compat.c b/src/os_compat.c new file mode 100644 index 0000000..07bcdd0 --- /dev/null +++ b/src/os_compat.c @@ -0,0 +1,584 @@ +#define _DEFAULT_SOURCE +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +#include "os_compat.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#include +#include +#include +#else +#include +#include +#include +#include +#include +#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 +} diff --git a/src/sandbox.c b/src/sandbox.c new file mode 100644 index 0000000..870dedc --- /dev/null +++ b/src/sandbox.c @@ -0,0 +1,61 @@ +#define _DEFAULT_SOURCE + +#include +#include +#include + +#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; +} diff --git a/src/session.c b/src/session.c new file mode 100644 index 0000000..7aeaaef --- /dev/null +++ b/src/session.c @@ -0,0 +1,363 @@ +#define _DEFAULT_SOURCE +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +#include "cst_agent.h" + +#include +#include +#include + +#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; +} diff --git a/src/tools/tools.c b/src/tools/tools.c new file mode 100644 index 0000000..df5afc4 --- /dev/null +++ b/src/tools/tools.c @@ -0,0 +1,784 @@ +#define _DEFAULT_SOURCE + +#include +#include +#include +#include + +#include "cst_agent.h" +#include "os_compat.h" + +#ifndef _WIN32 +#include +#else +#include +#include +#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: + * + * 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; +} diff --git a/src/tui.c b/src/tui.c new file mode 100644 index 0000000..c663d77 --- /dev/null +++ b/src/tui.c @@ -0,0 +1,2761 @@ +#define _GNU_SOURCE + +#include "cst_agent.h" +#include "sashidown.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#else +#include +#endif + +/* Curses: ncursesw on Unix, PDCurses (wide) on Windows. */ +#if defined(CST_USE_PDCURSES) || defined(_WIN32) +#ifndef PDC_WIDE +#define PDC_WIDE 1 +#endif +#ifndef NCURSES_MOUSE_VERSION +#define NCURSES_MOUSE_VERSION 2 +#endif +#include +#ifndef BUTTON4_PRESSED +#define BUTTON4_PRESSED 0 +#endif +#ifndef BUTTON5_PRESSED +#define BUTTON5_PRESSED 0 +#endif +#ifndef KEY_RESIZE +#define KEY_RESIZE 0632 +#endif +/* MinGW often lacks wcwidth; simple East-Asian / ASCII heuristic. */ +static int cst_wcwidth(wchar_t wc) { + if (wc == 0) + return 0; + if (wc < 32 || (wc >= 0x7f && wc < 0xa0)) + return -1; + if (wc >= 0x1100 && + (wc <= 0x115f || wc == 0x2329 || wc == 0x232a || + (wc >= 0x2e80 && wc <= 0xa4cf && wc != 0x303f) || + (wc >= 0xac00 && wc <= 0xd7a3) || (wc >= 0xf900 && wc <= 0xfaff) || + (wc >= 0xfe10 && wc <= 0xfe19) || (wc >= 0xfe30 && wc <= 0xfe6f) || + (wc >= 0xff00 && wc <= 0xff60) || (wc >= 0xffe0 && wc <= 0xffe6) || + (wc >= 0x20000 && wc <= 0x2fffd) || (wc >= 0x30000 && wc <= 0x3fffd))) + return 2; + return 1; +} +#define wcwidth cst_wcwidth +#else +#include +#endif + +#ifdef _WIN32 +static void cst_usleep(unsigned usec) { + Sleep((usec + 999) / 1000); +} +static int cst_clock_gettime_mono(struct timespec *ts) { + static LARGE_INTEGER freq; + static int init; + LARGE_INTEGER c; + if (!init) { + QueryPerformanceFrequency(&freq); + init = 1; + } + QueryPerformanceCounter(&c); + ts->tv_sec = (time_t)(c.QuadPart / freq.QuadPart); + ts->tv_nsec = + (long)(((c.QuadPart % freq.QuadPart) * 1000000000ULL) / freq.QuadPart); + return 0; +} +#define usleep(us) cst_usleep((unsigned)(us)) +#define clock_gettime(clk, ts) cst_clock_gettime_mono(ts) +#ifndef CLOCK_MONOTONIC +#define CLOCK_MONOTONIC 1 +#endif +#endif + +/* + * OpenCode-inspired C ncurses/PDCurses TUI: + * structured transcript blocks, thinking/tool cards, permissions, cancel. + */ + +#define INPUT_CAP 4096 +#define SCROLL_SPEED 3 +#define REPLAY_MSGS 30 +#define MD_INIT (64 * 1024) +#define ANSI_INIT (64 * 1024) +#define TOOL_PREVIEW_LINES 3 +#define DIFF_FOLD_LINES 40 +#define BLOCKS_INIT 64 +#define MAX_RENDER_LINES 65536 +#define PROMPT_HIST_CAP 50 +#define QUEUE_CAP 3 +#define INPUT_MAX_ROWS 6 + +/* Color pair indices */ +enum { + CP_HEADER = 1, + CP_STATUS, + CP_INPUT, + CP_PRIMARY, /* user / accents */ + CP_ASSIST, /* cyan assistant */ + CP_MUTED, + CP_SUCCESS, + CP_ERROR, + CP_TOOL, + CP_PANEL, /* transcript bg */ + CP_SGR_GREEN, + CP_SGR_YELLOW, + CP_SGR_CYAN, + CP_SGR_MAGENTA, + /* OpenCode-style structured diff (bg distinguishes add/del/context) */ + CP_DIFF_ADD, + CP_DIFF_DEL, + CP_DIFF_CTX +}; + +typedef enum { + BLK_USER = 0, + BLK_ASSISTANT, + BLK_THINK, + BLK_TOOL, + BLK_STATUS, + BLK_SYSTEM +} block_kind_t; + +typedef struct { + block_kind_t kind; + char *title; + char *body; /* raw text / markdown / tool output */ + char *render; /* ANSI for assistant; NULL → draw body */ + int expanded; + char meta[256]; /* phase / timing / detail */ + char tool_name[CTL_MAX_NAME]; + char tool_id[CTL_MAX_ID]; +} block_t; + +typedef struct { + block_t *items; + int n; + int cap; +} blocks_t; + +typedef struct { + cst_agent_t *agent; + WINDOW *header; + WINDOW *logw; + WINDOW *status; + WINDOW *input; + int rows, cols; + int input_rows; /* 1..INPUT_MAX_ROWS */ + + blocks_t hist; /* sealed blocks */ + block_t live; /* streaming assistant (kind set when as_live) */ + int as_live; + char *md_live; + size_t md_len; + size_t md_cap; + + int log_scroll; + char inbuf[INPUT_CAP]; + size_t in_len; + char status_msg[512]; + + int busy; + int details_all; /* /details: expand all tool bodies */ + int awaiting_perm; + int think_idx; /* hist index of open think, or -1 */ + int last_tool_idx; + int focus_think_idx; + struct timespec think_t0; + int think_timing; /* think_t0 valid */ + + /* Prompt history (newest at end). hist_browse=-1 means live buffer. */ + char *prompt_hist[PROMPT_HIST_CAP]; + int prompt_hist_n; + int hist_browse; + + /* Busy-turn FIFO */ + char *queue[QUEUE_CAP]; + int queue_n; + + /* Session-scoped always-allow (process lifetime). */ + int always_shell; + int always_write; /* write_file + apply_patch */ + + /* Permission handshake (worker → main). */ + volatile int perm_ask; + volatile int perm_ans; /* -1 waiting, 0 deny, 1 allow */ + char perm_tool[CTL_MAX_NAME]; + char perm_detail[512]; + + pthread_mutex_t mu; + volatile int dirty; + volatile int turn_done; + int turn_rc; + char turn_err[512]; +} tui_t; + +/* --- UTF-8 helpers -------------------------------------------------------- */ + +static size_t utf8_prev_len(const char *s, size_t len) { + if (len == 0) + return 0; + size_t i = len - 1; + while (i > 0 && ((unsigned char)s[i] & 0xC0) == 0x80) + i--; + return len - i; +} + +static int utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) { + if (avail == 0) + return -1; + mbstate_t st; + memset(&st, 0, sizeof(st)); + size_t n = mbrtowc(wc, s, avail, &st); + if (n == (size_t)-1 || n == (size_t)-2 || n == 0) { + *wc = (unsigned char)s[0]; + *eaten = 1; + return 0; + } + *eaten = n; + return 0; +} + +static int utf8_width(wchar_t wc) { + int w = wcwidth(wc); + if (w < 0) + return 1; + return w; +} + +static int utf8_cols(const char *s, size_t n) { + int cols = 0; + size_t i = 0; + while (i < n) { + wchar_t wc; + size_t eaten; + utf8_decode(s + i, n - i, &wc, &eaten); + cols += utf8_width(wc); + i += eaten; + } + return cols; +} + +static size_t utf8_fit_cols(const char *s, size_t n, int max_cols) { + int cols = 0; + size_t i = 0; + while (i < n) { + wchar_t wc; + size_t eaten; + utf8_decode(s + i, n - i, &wc, &eaten); + int w = utf8_width(wc); + if (cols + w > max_cols) + break; + cols += w; + i += eaten; + } + return i; +} + +static const char *utf8_tail_cols(const char *s, size_t n, int max_cols, + size_t *out_len) { + if (utf8_cols(s, n) <= max_cols) { + *out_len = n; + return s; + } + size_t i = 0; + while (i < n) { + wchar_t wc; + size_t eaten; + utf8_decode(s + i, n - i, &wc, &eaten); + i += eaten; + if (utf8_cols(s + i, n - i) <= max_cols) { + *out_len = n - i; + return s + i; + } + } + *out_len = 0; + return s + n; +} + +static size_t ansi_skip(const char *s, size_t n) { + if (n < 2 || (unsigned char)s[0] != 0x1b) + return 0; + if (s[1] == '[') { + size_t i = 2; + while (i < n) { + unsigned char c = (unsigned char)s[i++]; + if (c >= 0x40 && c <= 0x7e) + return i; + } + return 0; + } + return 2; +} + +static char *xstrdup(const char *s) { + if (!s) + s = ""; + size_t n = strlen(s); + char *p = malloc(n + 1); + if (!p) + return NULL; + memcpy(p, s, n + 1); + return p; +} + +static char *xstrndup(const char *s, size_t n) { + char *p = malloc(n + 1); + if (!p) + return NULL; + memcpy(p, s, n); + p[n] = 0; + return p; +} + +/* --- theme ---------------------------------------------------------------- */ + +/* OpenCode dark panel — never inherit terminal default bg. */ +static void theme_init(void) { + if (!has_colors()) + return; + start_color(); + /* Do NOT call use_default_colors(): -1 would leak the terminal theme. */ + + short primary = COLOR_YELLOW; + short panel = COLOR_BLACK; + short muted = COLOR_CYAN; + short success = COLOR_GREEN; + short err = COLOR_RED; + short assist = COLOR_CYAN; + short text = COLOR_WHITE; + + if (COLORS >= 256) { + /* Prefer xterm-256 slots so we don't bleach the 8 ANSI base colors. + * panel #121212→233, primary #fab283→216, muted #808080→244 */ + primary = 216; + panel = 233; /* #121212 — darker than 234 to avoid washed look */ + muted = 244; + success = 114; + err = 167; + assist = 74; /* soft cyan, not neon */ + text = 250; /* #bcbcbc — not near-white */ + if (can_change_color()) { + /* Tune those slots toward exact OpenCode hex */ + init_color(216, 980, 698, 514); /* #fab283 */ + init_color(233, 70, 70, 70); /* #121212 */ + init_color(244, 502, 502, 502); /* #808080 */ + init_color(250, 720, 720, 720); /* body text */ + init_color(114, 400, 750, 450); + init_color(167, 850, 320, 320); + init_color(74, 350, 700, 750); + } + } else if (can_change_color() && COLORS >= 8) { + init_color(COLOR_BLACK, 70, 70, 70); /* panel #121212 */ + init_color(COLOR_YELLOW, 980, 698, 514); /* primary */ + init_color(COLOR_CYAN, 502, 502, 502); /* muted (reuse cyan slot) */ + init_color(COLOR_WHITE, 720, 720, 720); /* body text */ + init_color(COLOR_GREEN, 400, 750, 450); + init_color(COLOR_RED, 850, 320, 320); + init_color(COLOR_BLUE, 350, 700, 750); /* assist */ + panel = COLOR_BLACK; + primary = COLOR_YELLOW; + muted = COLOR_CYAN; + text = COLOR_WHITE; + success = COLOR_GREEN; + err = COLOR_RED; + assist = COLOR_BLUE; + } + + /* Header is dark too — primary only as text accent (not a bright bar). */ + init_pair(CP_HEADER, primary, panel); + init_pair(CP_STATUS, muted, panel); + init_pair(CP_INPUT, text, panel); + init_pair(CP_PRIMARY, primary, panel); + init_pair(CP_ASSIST, assist, panel); + init_pair(CP_MUTED, muted, panel); + init_pair(CP_SUCCESS, success, panel); + init_pair(CP_ERROR, err, panel); + init_pair(CP_TOOL, primary, panel); + init_pair(CP_PANEL, text, panel); + init_pair(CP_SGR_GREEN, success, panel); + init_pair(CP_SGR_YELLOW, primary, panel); + init_pair(CP_SGR_CYAN, assist, panel); + if (COLORS >= 256) + init_pair(CP_SGR_MAGENTA, 176, panel); + else + init_pair(CP_SGR_MAGENTA, COLOR_MAGENTA, panel); + + /* Diff rows: high-contrast xterm-256 palette only. + * Do NOT init_color() cube indices 16–21 — many terminals ignore it and leave + * default blues, which made add/del rows unreadable (dark-on-blue). */ + if (COLORS >= 256) { + init_pair(CP_DIFF_ADD, 157, 22); /* #afff87 on #005f00 */ + init_pair(CP_DIFF_DEL, 203, 52); /* #ff5f5f on #5f0000 */ + init_pair(CP_DIFF_CTX, 252, panel); /* bright gray on panel */ + } else { + init_pair(CP_DIFF_ADD, COLOR_GREEN, COLOR_BLACK); + init_pair(CP_DIFF_DEL, COLOR_RED, COLOR_BLACK); + init_pair(CP_DIFF_CTX, COLOR_WHITE, COLOR_BLACK); + } +} + +static void theme_paint_root(void) { + if (!has_colors()) + return; + wbkgd(stdscr, COLOR_PAIR(CP_PANEL)); + werase(stdscr); + wnoutrefresh(stdscr); +} + +/* --- blocks --------------------------------------------------------------- */ + +static void block_clear(block_t *b) { + if (!b) + return; + free(b->title); + free(b->body); + free(b->render); + memset(b, 0, sizeof(*b)); +} + +static void blocks_init(blocks_t *bs) { + bs->items = calloc((size_t)BLOCKS_INIT, sizeof(block_t)); + bs->n = 0; + bs->cap = bs->items ? BLOCKS_INIT : 0; +} + +static void blocks_free(blocks_t *bs) { + if (!bs) + return; + for (int i = 0; i < bs->n; i++) + block_clear(&bs->items[i]); + free(bs->items); + bs->items = NULL; + bs->n = bs->cap = 0; +} + +static void blocks_clear(blocks_t *bs) { + for (int i = 0; i < bs->n; i++) + block_clear(&bs->items[i]); + bs->n = 0; +} + +static block_t *blocks_push(blocks_t *bs) { + if (!bs->items) + return NULL; + if (bs->n >= bs->cap) { + int nc = bs->cap * 2; + block_t *ni = realloc(bs->items, (size_t)nc * sizeof(block_t)); + if (!ni) + return NULL; + memset(ni + bs->cap, 0, (size_t)(nc - bs->cap) * sizeof(block_t)); + bs->items = ni; + bs->cap = nc; + } + block_t *b = &bs->items[bs->n++]; + memset(b, 0, sizeof(*b)); + return b; +} + +static void block_set_title(block_t *b, const char *t) { + free(b->title); + b->title = xstrdup(t ? t : ""); +} + +static void block_set_body(block_t *b, const char *s, size_t n) { + free(b->body); + b->body = n ? xstrndup(s, n) : xstrdup(""); +} + +static void block_append_body(block_t *b, const char *s, size_t n) { + if (!s || !n) + return; + size_t old = b->body ? strlen(b->body) : 0; + char *nb = realloc(b->body, old + n + 1); + if (!nb) + return; + memcpy(nb + old, s, n); + nb[old + n] = 0; + b->body = nb; +} + +/* Markdown → ANSI via Sashidown. */ +static int md_to_ansi_dyn(const char *md, size_t md_len, char **outp, + size_t *out_len) { + if (!md || !outp) + return -1; + if (md_len == 0) { + *outp = NULL; + if (out_len) + *out_len = 0; + return 0; + } + size_t cap = ANSI_INIT; + char *out = malloc(cap); + if (!out) + return -1; + for (;;) { + size_t n = 0; + int rc = sashidown_convert(md, md_len, out, cap, &n, SASHIDOWN_FMT_ANSI); + if (rc == 0) { + *outp = out; + if (out_len) + *out_len = n; + return 0; + } + size_t nc = cap * 2; + if (nc < md_len * 4) + nc = md_len * 4; + if (nc > 16 * 1024 * 1024) { + free(out); + out = malloc(md_len + 1); + if (!out) + return -1; + memcpy(out, md, md_len); + out[md_len] = 0; + *outp = out; + if (out_len) + *out_len = md_len; + return 0; + } + char *nb = realloc(out, nc); + if (!nb) { + free(out); + return -1; + } + out = nb; + cap = nc; + } +} + +static void block_render_assistant(block_t *b) { + free(b->render); + b->render = NULL; + if (!b->body || !b->body[0]) + return; + char *ansi = NULL; + size_t n = 0; + if (md_to_ansi_dyn(b->body, strlen(b->body), &ansi, &n) == 0 && ansi) { + b->render = ansi; + } +} + +static const char *tool_prefix(const char *name) { + if (!name) + return "•"; + if (strcmp(name, "run_shell") == 0) + return "$"; + if (strcmp(name, "write_file") == 0 || strcmp(name, "apply_patch") == 0) + return "←"; + if (strcmp(name, "read_file") == 0) + return "✱"; + return "•"; +} + +static void tool_arg_summary(const ctl_tool_call_t *call, char *out, + size_t out_sz) { + out[0] = 0; + if (!call || out_sz == 0) + return; + const char *prefer = NULL; + if (strcmp(call->name, "run_shell") == 0) + prefer = "cmd"; + else if (strcmp(call->name, "read_file") == 0 || + strcmp(call->name, "write_file") == 0) + prefer = "path"; + else if (strcmp(call->name, "apply_patch") == 0) + prefer = "patch"; + + for (int i = 0; i < call->nargs; i++) { + if (prefer && strcmp(call->args[i].key, prefer) != 0) + continue; + const char *v = call->args[i].value ? call->args[i].value : ""; + size_t len = call->args[i].value_len; + if (len == 0 && v[0]) + len = strlen(v); + if (len > 80) + len = 80; + snprintf(out, out_sz, "%.*s%s", (int)len, v, len >= 80 ? "…" : ""); + return; + } + if (call->nargs > 0 && call->args[0].value) { + size_t len = call->args[0].value_len; + if (len == 0) + len = strlen(call->args[0].value); + if (len > 80) + len = 80; + snprintf(out, out_sz, "%.*s%s", (int)len, call->args[0].value, + len >= 80 ? "…" : ""); + } +} + +static void tui_lock(tui_t *t) { pthread_mutex_lock(&t->mu); } +static void tui_unlock(tui_t *t) { pthread_mutex_unlock(&t->mu); } +static void tui_mark_dirty(tui_t *t) { t->dirty = 1; } + +static void set_status_locked(tui_t *t, const char *msg) { + snprintf(t->status_msg, sizeof(t->status_msg), "%s", msg ? msg : ""); + tui_mark_dirty(t); +} + +static void set_status(tui_t *t, const char *msg) { + tui_lock(t); + set_status_locked(t, msg); + tui_unlock(t); +} + +static void short_session_id(tui_t *t, char *out, size_t out_sz) { + out[0] = 0; + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + if (!cfg) + return; + char id[128] = {0}; + if (cst_agent_store_get_current(cfg->workspace_root, id, sizeof(id)) == 0 && + id[0]) + snprintf(out, out_sz, "%.8s", id); +} + +static void status_idle(tui_t *t) { + char sid[16] = {0}; + short_session_id(t, sid, sizeof(sid)); + if (t->queue_n > 0) { + snprintf(t->status_msg, sizeof(t->status_msg), + "ready · queued %d · empty Enter = next · /help%s%s", t->queue_n, + sid[0] ? " · " : "", sid[0] ? sid : ""); + } else if (sid[0]) { + snprintf(t->status_msg, sizeof(t->status_msg), + "ready · Esc cancel · Ctrl+N newline · /help · session %s", sid); + } else { + snprintf(t->status_msg, sizeof(t->status_msg), + "ready · Esc cancel · Ctrl+N newline · /help"); + } +} + +/* --- live assistant ------------------------------------------------------- */ + +static void live_end_cleanup(tui_t *t) { + block_clear(&t->live); + free(t->md_live); + t->md_live = NULL; + t->md_len = 0; + t->md_cap = 0; + t->as_live = 0; +} + +static void live_begin(tui_t *t) { + live_end_cleanup(t); + t->md_cap = MD_INIT; + t->md_live = calloc(1, t->md_cap); + t->md_len = 0; + t->live.kind = BLK_ASSISTANT; + block_set_title(&t->live, "Assistant"); + t->live.expanded = 1; + t->as_live = 1; +} + +static void live_feed(tui_t *t, const char *text, size_t len) { + if (!t->as_live || !text || !len) + return; + if (t->md_len + len + 1 > t->md_cap) { + size_t nc = t->md_cap * 2; + while (nc < t->md_len + len + 1) + nc *= 2; + char *nb = realloc(t->md_live, nc); + if (!nb) + return; + t->md_live = nb; + t->md_cap = nc; + } + memcpy(t->md_live + t->md_len, text, len); + t->md_len += len; + t->md_live[t->md_len] = 0; + /* During stream show raw markdown; convert on seal. */ + free(t->live.body); + t->live.body = xstrndup(t->md_live, t->md_len); + free(t->live.render); + t->live.render = NULL; +} + +static void live_seal(tui_t *t) { + if (!t->as_live) + return; + block_t *b = blocks_push(&t->hist); + if (b) { + b->kind = BLK_ASSISTANT; + block_set_title(b, "Assistant"); + if (t->md_len) + block_set_body(b, t->md_live, t->md_len); + else if (t->live.body) + block_set_body(b, t->live.body, strlen(t->live.body)); + b->expanded = 1; + block_render_assistant(b); + } + live_end_cleanup(t); +} + +static void collapse_open_think(tui_t *t) { + if (t->think_idx < 0 || t->think_idx >= t->hist.n) + return; + block_t *th = &t->hist.items[t->think_idx]; + if (th->kind != BLK_THINK) + return; + th->expanded = 0; + char title[64]; + if (t->think_timing) { + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + double sec = (double)(now.tv_sec - t->think_t0.tv_sec) + + (double)(now.tv_nsec - t->think_t0.tv_nsec) / 1e9; + if (sec < 0) + sec = 0; + if (sec < 1.0) + snprintf(title, sizeof(title), "Thought · %.1fs", sec); + else + snprintf(title, sizeof(title), "Thought · %.1fs", sec); + t->think_timing = 0; + } else { + snprintf(title, sizeof(title), "Thought"); + } + block_set_title(th, title); + t->think_idx = -1; +} + +static int find_tool_block(tui_t *t, const char *id) { + if (!id || !id[0]) + return -1; + for (int i = t->hist.n - 1; i >= 0; i--) { + block_t *b = &t->hist.items[i]; + if (b->kind == BLK_TOOL && strcmp(b->tool_id, id) == 0) + return i; + } + return -1; +} + +static void attach_tool_output_from_session(tui_t *t, block_t *b) { + if (!b || b->kind != BLK_TOOL || !b->tool_id[0]) + return; + cst_session_t *s = cst_agent_session(t->agent); + if (!s) + return; + int n = cst_session_count(s); + for (int i = n - 1; i >= 0; i--) { + const cst_message_t *m = cst_session_get(s, i); + if (!m || m->role != CST_ROLE_TOOL) + continue; + if (strcmp(m->tool_call_id, b->tool_id) != 0) + continue; + if (m->content) + block_set_body(b, m->content, strlen(m->content)); + return; + } +} + +/* --- drawing: SGR --------------------------------------------------------- */ + +typedef struct { + int attrs; + int pair; +} sty_t; + +static void sgr_apply(sty_t *st, int code) { + switch (code) { + case 0: + st->attrs = 0; + st->pair = 0; + break; + case 1: + st->attrs |= A_BOLD; + break; + case 2: + st->attrs |= A_DIM; + break; + case 3: +#ifdef A_ITALIC + st->attrs |= A_ITALIC; +#endif + break; + case 4: + st->attrs |= A_UNDERLINE; + break; + case 7: + st->attrs |= A_REVERSE; + break; + case 22: + st->attrs &= ~(A_BOLD | A_DIM); + break; + case 23: +#ifdef A_ITALIC + st->attrs &= ~A_ITALIC; +#endif + break; + case 24: + st->attrs &= ~A_UNDERLINE; + break; + case 27: + st->attrs &= ~A_REVERSE; + break; + case 32: + case 92: + st->pair = CP_SGR_GREEN; + break; + case 33: + case 93: + st->pair = CP_SGR_YELLOW; + break; + case 36: + case 96: + st->pair = CP_SGR_CYAN; + break; + case 35: + case 95: + st->pair = CP_SGR_MAGENTA; + break; + case 39: + st->pair = 0; + break; + default: + break; + } +} + +static size_t sgr_parse(const char *s, size_t n, sty_t *st) { + if (n < 3 || (unsigned char)s[0] != 0x1b || s[1] != '[') + return 0; + size_t i = 2; + int val = 0, have = 0; + while (i < n) { + unsigned char c = (unsigned char)s[i++]; + if (c >= '0' && c <= '9') { + val = val * 10 + (c - '0'); + have = 1; + } else if (c == ';') { + sgr_apply(st, have ? val : 0); + val = 0; + have = 0; + } else if (c == 'm') { + sgr_apply(st, have ? val : 0); + return i; + } else { + return 0; + } + } + return 0; +} + +static int put_wc(WINDOW *win, int y, int col, wchar_t wc, int attrs) { + cchar_t cc; + wchar_t wstr[2] = {wc, 0}; + short pair = (short)PAIR_NUMBER(attrs); + attr_t a = (attr_t)(attrs & ~(attr_t)A_COLOR); + if (setcchar(&cc, wstr, a, pair, NULL) == ERR) { + if (wc < 128) + mvwaddch(win, y, col, (chtype)wc | (chtype)attrs); + else + mvwaddstr(win, y, col, "?"); + return 1; + } + mvwadd_wch(win, y, col, &cc); + return utf8_width(wc); +} + +/* Flatten blocks into display lines (plain text with optional ANSI in body). */ +typedef struct { + char *text; /* owned line content (may include ANSI) */ + int pair; /* base color pair for whole line (0 = default) */ + int bold; + int dim; + int indent; /* leading columns already accounted in text */ +} dline_t; + +typedef struct { + dline_t *lines; + int n; + int cap; +} dlines_t; + +static void dlines_free(dlines_t *dl) { + if (!dl) + return; + for (int i = 0; i < dl->n; i++) + free(dl->lines[i].text); + free(dl->lines); + dl->lines = NULL; + dl->n = dl->cap = 0; +} + +static int dlines_push(dlines_t *dl, const char *text, int pair, int bold, + int dim) { + if (dl->n >= dl->cap) { + int nc = dl->cap ? dl->cap * 2 : 256; + dline_t *ni = realloc(dl->lines, (size_t)nc * sizeof(dline_t)); + if (!ni) + return -1; + dl->lines = ni; + dl->cap = nc; + } + dline_t *L = &dl->lines[dl->n++]; + L->text = xstrdup(text ? text : ""); + L->pair = pair; + L->bold = bold; + L->dim = dim; + L->indent = 0; + return 0; +} + +static void push_wrapped(dlines_t *dl, const char *prefix, const char *body, + int width, int pair, int bold, int dim, int ansi) { + (void)ansi; + char line[4096]; + int pref_cols = prefix ? utf8_cols(prefix, strlen(prefix)) : 0; + int body_w = width - pref_cols; + if (body_w < 8) + body_w = 8; + + if (!body || !body[0]) { + snprintf(line, sizeof(line), "%s", prefix ? prefix : ""); + dlines_push(dl, line, pair, bold, dim); + return; + } + + /* Walk body; for ANSI text, wrap on visible columns skipping SGR. */ + size_t n = strlen(body); + size_t i = 0; + int first = 1; + while (i < n && dl->n < MAX_RENDER_LINES) { + size_t start = i; + int col = 0; + while (i < n) { + size_t sg = 0; + if ((unsigned char)body[i] == 0x1b) { + sty_t dummy = {0}; + sg = sgr_parse(body + i, n - i, &dummy); + if (!sg) + sg = ansi_skip(body + i, n - i); + if (sg) { + i += sg; + continue; + } + } + if (body[i] == '\n') { + i++; + break; + } + wchar_t wc; + size_t eaten; + utf8_decode(body + i, n - i, &wc, &eaten); + int cw = utf8_width(wc); + if (col + cw > body_w && col > 0) + break; + col += cw; + i += eaten; + if (col >= body_w) + break; + } + size_t chunk = i - start; + /* trim trailing newline already consumed */ + if (chunk > 0 && body[start + chunk - 1] == '\n') + chunk--; + if (first && prefix) { + if (chunk + strlen(prefix) + 1 < sizeof(line)) { + memcpy(line, prefix, strlen(prefix)); + memcpy(line + strlen(prefix), body + start, chunk); + line[strlen(prefix) + chunk] = 0; + } else { + snprintf(line, sizeof(line), "%s", prefix); + } + first = 0; + } else { + /* continuation indent matching prefix visual width */ + int pad = pref_cols; + if (pad < 0) + pad = 0; + if (pad > 40) + pad = 40; + memset(line, ' ', (size_t)pad); + if (chunk + (size_t)pad + 1 < sizeof(line)) { + memcpy(line + pad, body + start, chunk); + line[pad + chunk] = 0; + } else { + line[pad] = 0; + } + } + dlines_push(dl, line, pair, bold, dim); + if (i < n && body[i - 1] != '\n' && col >= body_w) { + /* soft wrap — already advanced */ + } + } +} + +static void push_body_preview(dlines_t *dl, const char *body, int width, + int pair, int expanded, int max_lines) { + if (!body || !body[0]) + return; + if (expanded) { + push_wrapped(dl, " ", body, width, pair, 0, 1, 0); + return; + } + const char *p = body; + int shown = 0; + while (*p && shown < max_lines) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + char tmp[1024]; + if (len >= sizeof(tmp)) + len = sizeof(tmp) - 1; + memcpy(tmp, p, len); + tmp[len] = 0; + push_wrapped(dl, " ", tmp, width, pair, 0, 1, 0); + shown++; + if (!nl) + break; + p = nl + 1; + } + if (*p) + dlines_push(dl, " …", pair, 0, 1); +} + +static int body_looks_like_diff(const char *body) { + if (!body || !body[0]) + return 0; + return strstr(body, "\n--- ") || strstr(body, "\n+++ ") || + strstr(body, "\n@@ ") || strstr(body, "\n@@-") || + strncmp(body, "--- ", 4) == 0 || strncmp(body, "diff ", 5) == 0 || + strstr(body, "\n--- a/") || strstr(body, "\n+++ b/"); +} + +static void diff_extract_path(const char *body, const char *fallback, + char *out, size_t out_sz) { + if (!out || out_sz == 0) + return; + out[0] = 0; + if (body) { + const char *p = body; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len > 6 && strncmp(p, "wrote ", 6) == 0) { + const char *s = p + 6; + size_t n = len - 6; + /* trim " (N bytes)" */ + const char *sp = memchr(s, ' ', n); + if (sp) + n = (size_t)(sp - s); + if (n >= out_sz) + n = out_sz - 1; + memcpy(out, s, n); + out[n] = 0; + return; + } + if (len > 6 && strncmp(p, "+++ b/", 6) == 0) { + size_t n = len - 6; + if (n >= out_sz) + n = out_sz - 1; + memcpy(out, p + 6, n); + out[n] = 0; + return; + } + if (len > 6 && strncmp(p, "--- a/", 6) == 0) { + size_t n = len - 6; + if (n >= out_sz) + n = out_sz - 1; + memcpy(out, p + 6, n); + out[n] = 0; + /* keep scanning for +++ / wrote */ + } + if (!nl) + break; + p = nl + 1; + } + } + if (!out[0] && fallback && fallback[0]) { + snprintf(out, out_sz, "%s", fallback); + } +} + +static int parse_hunk_at(const char *line, int *old_ln, int *new_ln) { + /* @@ -old[,n] +new[,n] @@ */ + if (line[0] != '@' || line[1] != '@') + return 0; + const char *p = line; + while (*p && *p != '-') + p++; + if (*p != '-') + return 0; + p++; + int o = 0, n = 0; + if (sscanf(p, "%d", &o) != 1) + return 0; + while (*p && *p != '+') + p++; + if (*p != '+') + return 0; + p++; + if (sscanf(p, "%d", &n) != 1) + return 0; + if (old_ln) + *old_ln = o; + if (new_ln) + *new_ln = n; + return 1; +} + +typedef struct { + int additions; + int deletions; + int content_lines; +} diff_stats_t; + +static void diff_count_stats(const char *body, diff_stats_t *st) { + memset(st, 0, sizeof(*st)); + if (!body) + return; + const char *p = body; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len == 0) { + /* empty */ + } else if (strncmp(p, "---", 3) == 0 || strncmp(p, "+++", 3) == 0 || + strncmp(p, "diff ", 5) == 0 || strncmp(p, "index ", 6) == 0 || + strncmp(p, "@@", 2) == 0 || strncmp(p, "wrote ", 6) == 0 || + (p[0] == '[' && strstr(p, "tool "))) { + /* skip meta */ + } else if (p[0] == '+') { + st->additions++; + st->content_lines++; + } else if (p[0] == '-') { + st->deletions++; + st->content_lines++; + } else if (p[0] == ' ' || p[0] == '\\') { + st->content_lines++; + } + if (!nl) + break; + p = nl + 1; + } +} + +/* OpenCode-style: strip ---/+++/@@, line numbers, add/del backgrounds. */ +static void push_structured_diff(dlines_t *dl, const char *body, int width, + int unlimited, int fold_limit) { + if (!body || !body[0]) + return; + int old_ln = 0, new_ln = 0; + int shown = 0; + int total_content = 0; + /* First pass: count content lines for fold message */ + { + diff_stats_t st; + diff_count_stats(body, &st); + total_content = st.content_lines; + } + int limit = unlimited ? 1000000 : fold_limit; + if (limit < 1) + limit = 1; + + const char *p = body; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + char raw[1024]; + if (len >= sizeof(raw)) + len = sizeof(raw) - 1; + memcpy(raw, p, len); + raw[len] = 0; + + int skip = 0; + if (raw[0] == 0) + skip = 1; + else if (strncmp(raw, "wrote ", 6) == 0) + skip = 1; + else if (raw[0] == '[' && strstr(raw, "tool ")) + skip = 1; + else if (strncmp(raw, "---", 3) == 0 || strncmp(raw, "+++", 3) == 0 || + strncmp(raw, "diff ", 5) == 0 || strncmp(raw, "index ", 6) == 0) + skip = 1; + else if (strncmp(raw, "@@", 2) == 0) { + parse_hunk_at(raw, &old_ln, &new_ln); + skip = 1; + } else if (raw[0] == '\\') { + /* "\ No newline at end of file" */ + skip = 1; + } + + if (!skip && (raw[0] == '+' || raw[0] == '-' || raw[0] == ' ')) { + if (shown >= limit) { + /* keep counting via total_content; stop emitting */ + break; + } + char kind = raw[0]; + const char *content = raw + 1; + int lnum = 0; + char mark = ' '; + int pair = CP_DIFF_CTX; + if (kind == '+') { + lnum = new_ln++; + mark = '+'; + pair = CP_DIFF_ADD; + } else if (kind == '-') { + lnum = old_ln++; + mark = '-'; + pair = CP_DIFF_DEL; + } else { + lnum = new_ln; + old_ln++; + new_ln++; + mark = ' '; + pair = CP_DIFF_CTX; + } + + char line[1200]; + /* │ 12+│ content — left border + gutter + body */ + int body_w = width - 8; + if (body_w < 8) + body_w = 8; + char clipped[1024]; + size_t clen = strlen(content); + /* column-clip roughly by bytes for MVP */ + if ((int)clen > body_w) { + int keep = body_w > 3 ? body_w - 3 : body_w; + memcpy(clipped, content, (size_t)keep); + if (body_w > 3) + memcpy(clipped + keep, "...", 4); + else + clipped[keep] = 0; + content = clipped; + } + snprintf(line, sizeof(line), "│%4d%c│ %s", lnum, mark, content); + /* Pad to width so row background fills like OpenCode. */ + { + int cols = utf8_cols(line, strlen(line)); + size_t llen = strlen(line); + while (cols < width && llen + 1 < sizeof(line)) { + line[llen++] = ' '; + line[llen] = 0; + cols++; + } + } + dlines_push(dl, line, pair, kind != ' ', 0); + shown++; + } + + if (!nl) + break; + p = nl + 1; + } + + if (!unlimited && total_content > shown) { + char more[96]; + snprintf(more, sizeof(more), "│ … %d more lines · /details", + total_content - shown); + dlines_push(dl, more, CP_MUTED, 0, 1); + } +} + +static void push_diff_or_body(dlines_t *dl, const char *body, int width, + int default_pair, int expanded, int max_lines, + int details_all) { + if (!body || !body[0]) + return; + if (!body_looks_like_diff(body)) { + push_body_preview(dl, body, width, default_pair, expanded || details_all, + max_lines); + return; + } + /* Auto-expanded write cards still fold at DIFF_FOLD_LINES unless /details. */ + int unlimited = details_all; + int fold = expanded ? DIFF_FOLD_LINES : max_lines; + if (!expanded && !details_all) + fold = 0; /* title-only when collapsed */ + if (fold == 0 && !unlimited) + return; + push_structured_diff(dl, body, width, unlimited, fold); +} + +static void block_to_lines(const block_t *b, int width, int details_all, + dlines_t *dl) { + char prefix[128]; + char head[512]; + + switch (b->kind) { + case BLK_USER: + snprintf(prefix, sizeof(prefix), "┃ "); + dlines_push(dl, "┃ You", CP_PRIMARY, 1, 0); + push_wrapped(dl, prefix, b->body ? b->body : "", width, CP_PRIMARY, 0, 0, + 0); + dlines_push(dl, "", 0, 0, 0); + break; + + case BLK_ASSISTANT: { + dlines_push(dl, "┃ Assistant", CP_ASSIST, 1, 0); + const char *src = b->render ? b->render : (b->body ? b->body : ""); + push_wrapped(dl, "┃ ", src, width, CP_ASSIST, 0, 0, 1); + dlines_push(dl, "", 0, 0, 0); + break; + } + + case BLK_THINK: + if (b->expanded) { + snprintf(head, sizeof(head), "▼ %s", b->title ? b->title : "Thought"); + dlines_push(dl, head, CP_MUTED, 0, 1); + push_wrapped(dl, " ", b->body ? b->body : "", width, CP_MUTED, 0, 1, 0); + } else { + /* One-liner: title + truncated body hint */ + const char *body = b->body ? b->body : ""; + size_t bl = strlen(body); + while (bl > 0 && (body[bl - 1] == '\n' || body[bl - 1] == ' ')) + bl--; + char one[200]; + size_t take = bl > 60 ? 60 : bl; + /* skip to first non-space for preview */ + size_t off = 0; + while (off < take && (body[off] == ' ' || body[off] == '\n')) + off++; + snprintf(one, sizeof(one), "▶ %s %.*s%s", + b->title ? b->title : "Thought", (int)(take > off ? take - off : 0), + body + off, bl > 60 ? "…" : ""); + dlines_push(dl, one, CP_MUTED, 0, 1); + } + break; + + case BLK_TOOL: { + const char *px = tool_prefix(b->tool_name); + int exp = details_all || b->expanded; + int denied = (strncmp(b->meta, "denied", 6) == 0); + int is_write = (strcmp(b->tool_name, "write_file") == 0); + int is_patch = (strcmp(b->tool_name, "apply_patch") == 0); + int is_file_edit = is_write || is_patch; + int done = (strncmp(b->meta, "exit=", 5) == 0) || + (strcmp(b->meta, "done") == 0) || + (b->body && body_looks_like_diff(b->body)); + + if (is_file_edit && !denied) { + char path[256]; + diff_extract_path(b->body, b->title, path, sizeof(path)); + if (!path[0] && b->title && b->title[0]) + snprintf(path, sizeof(path), "%s", b->title); + if (!path[0]) + snprintf(path, sizeof(path), "file"); + + diff_stats_t st = {0}; + if (b->body) + diff_count_stats(b->body, &st); + + char head[512]; + if (done) { + /* OpenCode: ← Wrote path / ← Edit path +N -M */ + snprintf(head, sizeof(head), + "│ ← %s %s \033[32m+%d\033[0m \033[31m-%d\033[0m", + is_write ? "Wrote" : "Edit", path, st.additions, st.deletions); + } else { + snprintf(head, sizeof(head), "│ ← %s %s %s", + is_write ? "Write" : "Edit", path, + b->meta[0] ? b->meta : "…"); + } + dlines_push(dl, head, CP_MUTED, 0, 0); + if (done || exp) + push_diff_or_body(dl, b->body, width, CP_MUTED, b->expanded, + TOOL_PREVIEW_LINES, details_all); + } else { + char status_short[96]; + status_short[0] = 0; + if (denied) { + snprintf(status_short, sizeof(status_short), "denied"); + } else if (strncmp(b->meta, "exit=", 5) == 0) { + snprintf(status_short, sizeof(status_short), "done %.80s", b->meta); + } else if (b->meta[0]) { + snprintf(status_short, sizeof(status_short), "%.80s", b->meta); + } + snprintf(head, sizeof(head), "%s %s %s%s%s", px, + b->tool_name[0] ? b->tool_name : "tool", + b->title ? b->title : "", + status_short[0] ? " " : "", status_short); + dlines_push(dl, head, denied ? CP_ERROR : CP_TOOL, 1, 0); + push_diff_or_body(dl, b->body, width, denied ? CP_ERROR : CP_MUTED, exp, + TOOL_PREVIEW_LINES, details_all); + } + break; + } + + case BLK_STATUS: + case BLK_SYSTEM: + snprintf(head, sizeof(head), "%s%s%s", b->title ? b->title : "", + (b->title && b->body && b->body[0]) ? " " : "", + b->body ? b->body : ""); + dlines_push(dl, head, CP_MUTED, 0, 1); + break; + } +} + +static void build_display(tui_t *t, dlines_t *dl, int width) { + dlines_free(dl); + dl->lines = NULL; + dl->n = dl->cap = 0; + for (int i = 0; i < t->hist.n; i++) + block_to_lines(&t->hist.items[i], width, t->details_all, dl); + if (t->as_live) + block_to_lines(&t->live, width, t->details_all, dl); +} + +static void draw_ansi_line(WINDOW *win, int y, int maxw, const char *s, + int base_pair, int base_bold, int base_dim) { + sty_t sty = {0}; + sty.pair = base_pair; + if (base_bold) + sty.attrs |= A_BOLD; + if (base_dim) + sty.attrs |= A_DIM; + int col = 0; + size_t n = s ? strlen(s) : 0; + size_t i = 0; + while (i < n && col < maxw) { + size_t sg = sgr_parse(s + i, n - i, &sty); + if (sg) { + i += sg; + continue; + } + size_t skip = ansi_skip(s + i, n - i); + if (skip) { + i += skip; + continue; + } + wchar_t wc; + size_t eaten; + utf8_decode(s + i, n - i, &wc, &eaten); + int cw = utf8_width(wc); + if (col + cw > maxw) + break; + int attrs = sty.attrs; + if (sty.pair && has_colors()) + attrs |= COLOR_PAIR(sty.pair); + else if (base_pair && has_colors()) + attrs |= COLOR_PAIR(base_pair); + put_wc(win, y, col, wc, attrs); + col += cw; + i += eaten; + } +} + +static void draw_header(tui_t *t) { + if (!t->header) + return; + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + werase(t->header); + if (has_colors()) + wbkgd(t->header, COLOR_PAIR(CP_HEADER)); + char sid[16] = {0}; + short_session_id(t, sid, sizeof(sid)); + if (cfg) + mvwprintw(t->header, 0, 1, " CST-Agent · %s · %s ", cfg->model, + sid[0] ? sid : "—"); + else + mvwprintw(t->header, 0, 1, " CST-Agent "); + wrefresh(t->header); +} + +static void draw_status(tui_t *t) { + if (!t->status) + return; + werase(t->status); + if (has_colors()) + wbkgd(t->status, COLOR_PAIR(CP_STATUS)); + const char *msg = t->status_msg[0] ? t->status_msg : "ready"; + size_t n = strlen(msg); + size_t fit = utf8_fit_cols(msg, n, t->cols > 2 ? t->cols - 2 : 1); + mvwaddnstr(t->status, 0, 1, msg, (int)fit); + wrefresh(t->status); +} + +static void draw_input(tui_t *t) { + if (!t->input) + return; + werase(t->input); + if (has_colors()) + wbkgd(t->input, COLOR_PAIR(CP_INPUT)); + + int h = t->input_rows > 0 ? t->input_rows : 1; + int maxw = t->cols - 4; + if (maxw < 8) + maxw = 8; + + /* Split inbuf into visual lines (soft wrap by cols + hard \n). */ + const char *s = t->inbuf; + size_t n = t->in_len; + int line = 0; + int col = 3; + mvwaddstr(t->input, 0, 0, " > "); + size_t i = 0; + int cur_line = 0, cur_col = 3; + /* Track cursor at end of buffer for simplicity. */ + while (i < n && line < h) { + if (s[i] == '\n') { + line++; + col = 3; + if (line < h) + mvwaddstr(t->input, line, 0, " "); + i++; + cur_line = line < h ? line : h - 1; + cur_col = 3; + continue; + } + wchar_t wc; + size_t eaten; + utf8_decode(s + i, n - i, &wc, &eaten); + int cw = utf8_width(wc); + if (col + cw > t->cols - 1 && col > 3) { + line++; + col = 3; + if (line < h) + mvwaddstr(t->input, line, 0, " "); + } + if (line >= h) + break; + put_wc(t->input, line, col, wc, has_colors() ? COLOR_PAIR(CP_INPUT) : 0); + col += cw; + i += eaten; + cur_line = line; + cur_col = col; + } + if (cur_col >= t->cols) + cur_col = t->cols - 1; + if (cur_line >= h) + cur_line = h - 1; + wmove(t->input, cur_line, cur_col); + wrefresh(t->input); +} + +static void draw_log(tui_t *t) { + if (!t->logw) + return; + werase(t->logw); + if (has_colors()) + wbkgd(t->logw, COLOR_PAIR(CP_PANEL)); + int h, w; + getmaxyx(t->logw, h, w); + if (h < 1 || w < 1) + return; + + dlines_t dl = {0}; + build_display(t, &dl, w); + int total = dl.n > 0 ? dl.n : 1; + int max_scroll = total - h; + if (max_scroll < 0) + max_scroll = 0; + if (t->log_scroll > max_scroll) + t->log_scroll = max_scroll; + if (t->log_scroll < 0) + t->log_scroll = 0; + int start = total - h - t->log_scroll; + if (start < 0) + start = 0; + + for (int row = 0; row < h; row++) { + int li = start + row; + if (li < 0 || li >= dl.n) + continue; + dline_t *L = &dl.lines[li]; + draw_ansi_line(t->logw, row, w, L->text, L->pair, L->bold, L->dim); + } + dlines_free(&dl); + wrefresh(t->logw); +} + +static void redraw_all(tui_t *t) { + theme_paint_root(); + draw_header(t); + draw_log(t); + draw_status(t); + draw_input(t); +} + +/* --- session reload ------------------------------------------------------- */ + +static void push_system_line(tui_t *t, const char *fmt, ...) { + char tmp[1024]; + va_list ap; + va_start(ap, fmt); + vsnprintf(tmp, sizeof(tmp), fmt, ap); + va_end(ap); + block_t *b = blocks_push(&t->hist); + if (!b) + return; + b->kind = BLK_SYSTEM; + block_set_body(b, tmp, strlen(tmp)); +} + +static void print_welcome(tui_t *t) { + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + push_system_line(t, "Welcome to CST-Agent"); + push_system_line( + t, "Commands: /new /session /model /compact /details /help | wheel"); + push_system_line(t, "Tab/t Thought · Enter tool/send · Ctrl+N newline · ↑↓ hist"); + push_system_line(t, "Esc cancel · busy Enter queues · perm y/n/a=always"); + if (cfg) + push_system_line(t, "Model %s @ %s", cfg->model, cfg->api_base); + push_system_line(t, ""); +} + +static void reload_hist_from_session(tui_t *t) { + live_end_cleanup(t); + blocks_clear(&t->hist); + t->log_scroll = 0; + t->think_idx = -1; + t->last_tool_idx = -1; + t->focus_think_idx = -1; + + print_welcome(t); + + cst_session_t *s = cst_agent_session(t->agent); + if (!s) + return; + int n = cst_session_count(s); + int non_sys = 0; + for (int i = 0; i < n; i++) { + const cst_message_t *m = cst_session_get(s, i); + if (m && m->role != CST_ROLE_SYSTEM) + non_sys++; + } + int skip = non_sys > REPLAY_MSGS ? non_sys - REPLAY_MSGS : 0; + int seen = 0; + for (int i = 0; i < n; i++) { + const cst_message_t *m = cst_session_get(s, i); + if (!m || m->role == CST_ROLE_SYSTEM) + continue; + if (seen++ < skip) + continue; + block_t *b = blocks_push(&t->hist); + if (!b) + continue; + if (m->role == CST_ROLE_USER) { + b->kind = BLK_USER; + block_set_title(b, "You"); + block_set_body(b, m->content ? m->content : "", + m->content ? strlen(m->content) : 0); + b->expanded = 1; + } else if (m->role == CST_ROLE_ASSISTANT) { + b->kind = BLK_ASSISTANT; + block_set_title(b, "Assistant"); + if (m->content) + block_set_body(b, m->content, strlen(m->content)); + b->expanded = 1; + block_render_assistant(b); + } else if (m->role == CST_ROLE_TOOL) { + b->kind = BLK_TOOL; + snprintf(b->tool_name, sizeof(b->tool_name), "%s", + m->tool_name[0] ? m->tool_name : "tool"); + snprintf(b->tool_id, sizeof(b->tool_id), "%s", m->tool_call_id); + snprintf(b->meta, sizeof(b->meta), "done"); + if (m->content) + block_set_body(b, m->content, strlen(m->content)); + { + char path[256]; + diff_extract_path(m->content, NULL, path, sizeof(path)); + block_set_title(b, path[0] ? path : ""); + } + /* OpenCode: write/edit cards open (still fold at DIFF_FOLD_LINES). */ + if (strcmp(b->tool_name, "write_file") == 0 || + strcmp(b->tool_name, "apply_patch") == 0) + b->expanded = 1; + else + b->expanded = 0; + t->last_tool_idx = t->hist.n - 1; + } + } +} + +/* --- layout --------------------------------------------------------------- */ + +static int input_visual_rows(const tui_t *t) { + if (t->in_len == 0) + return 1; + int rows = 1; + int col = 3; + size_t i = 0; + while (i < t->in_len) { + if (t->inbuf[i] == '\n') { + rows++; + col = 3; + i++; + continue; + } + wchar_t wc; + size_t eaten; + utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten); + int cw = utf8_width(wc); + if (col + cw > t->cols - 1 && col > 3) { + rows++; + col = 3; + } + col += cw; + i += eaten; + } + if (rows < 1) + rows = 1; + if (rows > INPUT_MAX_ROWS) + rows = INPUT_MAX_ROWS; + return rows; +} + +static void layout(tui_t *t) { + getmaxyx(stdscr, t->rows, t->cols); + theme_paint_root(); + if (t->header) + delwin(t->header); + if (t->logw) + delwin(t->logw); + if (t->status) + delwin(t->status); + if (t->input) + delwin(t->input); + + t->input_rows = input_visual_rows(t); + int chrome = 1 + 1 + t->input_rows; /* header + status + input */ + if (chrome >= t->rows) + chrome = t->rows - 1; + int log_h = t->rows - chrome; + if (log_h < 1) + log_h = 1; + + t->header = newwin(1, t->cols, 0, 0); + t->logw = newwin(log_h, t->cols, 1, 0); + t->status = newwin(1, t->cols, 1 + log_h, 0); + t->input = newwin(t->input_rows, t->cols, 1 + log_h + 1, 0); + if (has_colors()) { + wbkgdset(t->header, COLOR_PAIR(CP_HEADER)); + wbkgdset(t->logw, COLOR_PAIR(CP_PANEL)); + wbkgdset(t->status, COLOR_PAIR(CP_STATUS)); + wbkgdset(t->input, COLOR_PAIR(CP_INPUT)); + } + scrollok(t->logw, FALSE); + keypad(t->input, TRUE); + mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, NULL); + mouseinterval(0); +} + +static void redraw_all(tui_t *t); +static int start_turn(tui_t *t, char *prompt); /* takes ownership of prompt */ + +static void layout_maybe_grow_input(tui_t *t) { + int need = input_visual_rows(t); + if (need != t->input_rows) { + layout(t); + wtimeout(t->input, 80); + redraw_all(t); + } else { + draw_input(t); + } +} + +static void input_put_wc(tui_t *t, wchar_t wc) { + char mb[8]; + mbstate_t st; + memset(&st, 0, sizeof(st)); + size_t n = wcrtomb(mb, wc, &st); + if (n == (size_t)-1 || n == 0) + return; + if (t->in_len + n >= INPUT_CAP) + return; + memcpy(t->inbuf + t->in_len, mb, n); + t->in_len += n; + t->inbuf[t->in_len] = 0; + t->hist_browse = -1; +} + +static void input_put_byte(tui_t *t, char ch) { + if (t->in_len + 1 >= INPUT_CAP) + return; + t->inbuf[t->in_len++] = ch; + t->inbuf[t->in_len] = 0; + t->hist_browse = -1; +} + +static void prompt_hist_push(tui_t *t, const char *prompt) { + if (!prompt || !prompt[0]) + return; + if (t->prompt_hist_n >= PROMPT_HIST_CAP) { + free(t->prompt_hist[0]); + memmove(t->prompt_hist, t->prompt_hist + 1, + (PROMPT_HIST_CAP - 1) * sizeof(char *)); + t->prompt_hist_n = PROMPT_HIST_CAP - 1; + } + t->prompt_hist[t->prompt_hist_n++] = strdup(prompt); + t->hist_browse = -1; +} + +static void hist_load_into_input(tui_t *t, int idx) { + if (idx < 0 || idx >= t->prompt_hist_n) + return; + const char *s = t->prompt_hist[idx]; + snprintf(t->inbuf, sizeof(t->inbuf), "%s", s ? s : ""); + t->in_len = strlen(t->inbuf); + t->hist_browse = idx; +} + +static int queue_push(tui_t *t, const char *prompt) { + if (!prompt || !prompt[0]) + return -1; + if (t->queue_n >= QUEUE_CAP) + return -1; + t->queue[t->queue_n] = strdup(prompt); + if (!t->queue[t->queue_n]) + return -1; + t->queue_n++; + return 0; +} + +static char *queue_pop(tui_t *t) { + if (t->queue_n <= 0) + return NULL; + char *p = t->queue[0]; + memmove(t->queue, t->queue + 1, (size_t)(t->queue_n - 1) * sizeof(char *)); + t->queue_n--; + t->queue[t->queue_n] = NULL; + return p; +} + +static void queue_free_all(tui_t *t) { + for (int i = 0; i < t->queue_n; i++) { + free(t->queue[i]); + t->queue[i] = NULL; + } + t->queue_n = 0; +} + +static int perm_is_always(const tui_t *t, const char *tool) { + if (!tool) + return 0; + if (strcmp(tool, "run_shell") == 0) + return t->always_shell; + if (strcmp(tool, "write_file") == 0 || strcmp(tool, "apply_patch") == 0) + return t->always_write; + return 0; +} + +static void perm_set_always(tui_t *t, const char *tool) { + if (!tool) + return; + if (strcmp(tool, "run_shell") == 0) + t->always_shell = 1; + else if (strcmp(tool, "write_file") == 0 || strcmp(tool, "apply_patch") == 0) + t->always_write = 1; +} + +/* --- hooks (may run on worker thread) ------------------------------------- */ + +static void on_status(const char *msg, void *ud) { + tui_t *t = ud; + tui_lock(t); + set_status_locked(t, msg); + tui_unlock(t); +} + +static void on_assistant_delta(const char *text, size_t len, void *ud) { + tui_t *t = ud; + tui_lock(t); + collapse_open_think(t); + if (!t->as_live) + live_begin(t); + live_feed(t, text, len); + t->log_scroll = 0; + set_status_locked(t, "streaming…"); + tui_mark_dirty(t); + tui_unlock(t); +} + +static void on_think_delta(const char *text, size_t len, void *ud) { + tui_t *t = ud; + tui_lock(t); + if (t->think_idx < 0) { + block_t *b = blocks_push(&t->hist); + if (b) { + b->kind = BLK_THINK; + block_set_title(b, "Thinking…"); + b->expanded = 1; + t->think_idx = t->hist.n - 1; + t->focus_think_idx = t->think_idx; + clock_gettime(CLOCK_MONOTONIC, &t->think_t0); + t->think_timing = 1; + } + } + if (t->think_idx >= 0 && t->think_idx < t->hist.n && text && len) + block_append_body(&t->hist.items[t->think_idx], text, len); + set_status_locked(t, "thinking…"); + t->log_scroll = 0; + tui_mark_dirty(t); + tui_unlock(t); +} + +static void on_tool_event(const char *phase, const ctl_tool_call_t *call, + const char *detail, void *ud) { + tui_t *t = ud; + tui_lock(t); + collapse_open_think(t); + if (t->as_live && + (strcmp(phase, "open") == 0 || strcmp(phase, "ready") == 0)) + live_seal(t); + if (!call) { + tui_unlock(t); + return; + } + + int idx = find_tool_block(t, call->id); + block_t *b = NULL; + if (idx < 0) { + b = blocks_push(&t->hist); + if (!b) { + tui_unlock(t); + return; + } + b->kind = BLK_TOOL; + snprintf(b->tool_name, sizeof(b->tool_name), "%s", call->name); + snprintf(b->tool_id, sizeof(b->tool_id), "%s", call->id); + b->expanded = 0; + idx = t->hist.n - 1; + } else { + b = &t->hist.items[idx]; + } + t->last_tool_idx = idx; + + char args[160]; + tool_arg_summary(call, args, sizeof(args)); + block_set_title(b, args); + + if (strcmp(phase, "open") == 0) { + snprintf(b->meta, sizeof(b->meta), "pending"); + set_status_locked(t, "tool pending…"); + } else if (strcmp(phase, "arg") == 0) { + snprintf(b->meta, sizeof(b->meta), "args…"); + } else if (strcmp(phase, "ready") == 0) { + snprintf(b->meta, sizeof(b->meta), "ready"); + set_status_locked(t, "tool confirmed"); + } else if (strcmp(phase, "run") == 0) { + snprintf(b->meta, sizeof(b->meta), "running"); + set_status_locked(t, "running tool…"); + } else if (strcmp(phase, "denied") == 0) { + snprintf(b->meta, sizeof(b->meta), "denied"); + block_set_body(b, "denied by user", strlen("denied by user")); + set_status_locked(t, "tool denied"); + } else if (strcmp(phase, "done") == 0) { + snprintf(b->meta, sizeof(b->meta), "%s", detail ? detail : "done"); + attach_tool_output_from_session(t, b); + /* Auto-expand write/patch cards so diffs are visible like OpenCode. */ + if (strcmp(call->name, "write_file") == 0 || + strcmp(call->name, "apply_patch") == 0) + b->expanded = 1; + set_status_locked(t, "tool done"); + } + + t->log_scroll = 0; + tui_mark_dirty(t); + tui_unlock(t); +} + +static int on_permission(const char *tool_name, const char *detail, void *ud) { + tui_t *t = ud; + + tui_lock(t); + if (perm_is_always(t, tool_name)) { + tui_unlock(t); + return 1; + } + t->awaiting_perm = 1; + t->perm_ask = 1; + t->perm_ans = -1; + snprintf(t->perm_tool, sizeof(t->perm_tool), "%s", + tool_name ? tool_name : "tool"); + snprintf(t->perm_detail, sizeof(t->perm_detail), "%s", + detail ? detail : ""); + { + char msg[480]; + snprintf(msg, sizeof(msg), "Allow %s? [y/n/a] a=always %.280s", + t->perm_tool, t->perm_detail); + set_status_locked(t, msg); + } + tui_unlock(t); + + for (;;) { + tui_lock(t); + int ans = t->perm_ans; + tui_unlock(t); + if (ans >= 0) { + tui_lock(t); + t->awaiting_perm = 0; + t->perm_ask = 0; + tui_unlock(t); + return ans; + } + usleep(20000); + } +} + +/* --- modal picker (↑↓ Enter Esc) ------------------------------------------ */ + +/* items: n rows of labels (each ≤127 chars). Returns index or -1. */ +static int picker_run(tui_t *t, const char *title, char items[][128], int n, + int cur_idx) { + if (n <= 0) + return -1; + if (cur_idx < 0 || cur_idx >= n) + cur_idx = 0; + + int sel = cur_idx; + int top = 0; + wtimeout(stdscr, -1); + keypad(stdscr, TRUE); + curs_set(0); + + for (;;) { + int rows, cols; + getmaxyx(stdscr, rows, cols); + int box_h = rows > 4 ? rows - 2 : rows; + if (box_h > n + 2) + box_h = n + 2; + if (box_h < 3) + box_h = rows > 0 ? rows : 3; + int visible = box_h - 2; + if (visible < 1) + visible = 1; + if (sel < top) + top = sel; + if (sel >= top + visible) + top = sel - visible + 1; + + werase(stdscr); + if (has_colors()) + wbkgd(stdscr, COLOR_PAIR(CP_PANEL)); + mvprintw(0, 1, "%s (↑↓ Enter · Esc cancel)", title ? title : "Select"); + for (int i = 0; i < visible && top + i < n; i++) { + int idx = top + i; + int y = 1 + i; + if (idx == sel) { + if (has_colors()) + attron(COLOR_PAIR(CP_PRIMARY) | A_BOLD); + else + attron(A_REVERSE); + } else if (has_colors()) { + attron(COLOR_PAIR(CP_MUTED)); + } + mvprintw(y, 2, "%s %.*s", idx == cur_idx ? "*" : " ", cols - 6, + items[idx]); + if (idx == sel) { + if (has_colors()) + attroff(COLOR_PAIR(CP_PRIMARY) | A_BOLD); + else + attroff(A_REVERSE); + } else if (has_colors()) { + attroff(COLOR_PAIR(CP_MUTED)); + } + } + refresh(); + + wint_t wch = 0; + int rc = get_wch(&wch); + if (rc == ERR) + continue; + if (rc == KEY_CODE_YES) { + if (wch == KEY_UP) { + if (sel > 0) + sel--; + continue; + } + if (wch == KEY_DOWN) { + if (sel + 1 < n) + sel++; + continue; + } + if (wch == KEY_RESIZE) + continue; + if (wch == KEY_ENTER) + break; + continue; + } + if (wch == 10 || wch == 13) /* LF/CR */ + break; + if (wch == 27) { /* Esc */ + sel = -1; + break; + } + } + + curs_set(1); + layout(t); + wtimeout(t->input, 80); + keypad(t->input, TRUE); + redraw_all(t); + return sel; +} + +/* --- slash commands ------------------------------------------------------- */ + +static void cmd_help(tui_t *t) { + push_system_line(t, ""); + push_system_line(t, "Slash commands:"); + push_system_line(t, " /new, /clear new session"); + push_system_line(t, " /session [id] pick or switch session"); + push_system_line(t, " /sessions same as /session"); + push_system_line(t, " /model [name] pick or set model"); + push_system_line(t, " /compact, /summarize compact context"); + push_system_line(t, " /details toggle tool body expand"); + push_system_line(t, " /help this help"); + push_system_line(t, " /quit, /q, /exit quit"); + push_system_line(t, "Keys: Tab/t Thought · Enter send/tool · Ctrl+N newline · ↑↓ hist"); + push_system_line(t, " Esc cancel · busy Enter queues · perm y/n/a"); + push_system_line(t, ""); +} + +static int handle_slash(tui_t *t, const char *line, int *should_quit) { + *should_quit = 0; + if (!line || line[0] != '/') + return 0; + + char buf[INPUT_CAP]; + snprintf(buf, sizeof(buf), "%s", line); + char *cmd = buf + 1; + while (*cmd == ' ') + cmd++; + char *arg = cmd; + while (*arg && *arg != ' ') + arg++; + if (*arg) { + *arg++ = 0; + while (*arg == ' ') + arg++; + } + + if (strcmp(cmd, "new") == 0 || strcmp(cmd, "clear") == 0) { + char id[128] = {0}; + if (cst_agent_new_session(t->agent, id, sizeof(id)) != 0) { + push_system_line(t, "Error: failed to create session"); + set_status(t, "new session failed"); + } else { + live_end_cleanup(t); + blocks_clear(&t->hist); + t->log_scroll = 0; + t->think_idx = -1; + print_welcome(t); + push_system_line(t, "(new session %s — empty)", id[0] ? id : "?"); + status_idle(t); + } + return 1; + } + + if (strcmp(cmd, "session") == 0 || strcmp(cmd, "sessions") == 0) { + const cst_agent_config_t *cfg = cst_agent_config(t->agent); + if (!cfg) { + push_system_line(t, "Error: no agent config"); + return 1; + } + if (!arg || !arg[0]) { + char ids[64][128]; + int n = cst_agent_store_list(cfg->workspace_root, ids, 64); + if (n <= 0) { + push_system_line(t, "Sessions: (none)"); + status_idle(t); + return 1; + } + char cur[128] = {0}; + cst_agent_store_get_current(cfg->workspace_root, cur, sizeof(cur)); + int cur_idx = 0; + for (int i = 0; i < n; i++) { + if (cur[0] && strcmp(ids[i], cur) == 0) { + cur_idx = i; + break; + } + } + int pick = picker_run(t, "Session", ids, n, cur_idx); + if (pick < 0) { + set_status(t, "session pick cancelled"); + return 1; + } + char chosen[128]; + snprintf(chosen, sizeof(chosen), "%s", ids[pick]); + if (cst_agent_switch_session(t->agent, chosen) != 0) { + push_system_line(t, "Error: cannot switch to session '%s'", chosen); + set_status(t, "switch failed"); + } else { + reload_hist_from_session(t); + push_system_line(t, "(switched to %s)", chosen); + status_idle(t); + } + return 1; + } + if (cst_agent_switch_session(t->agent, arg) != 0) { + push_system_line(t, "Error: cannot switch to session '%s'", arg); + set_status(t, "switch failed"); + } else { + reload_hist_from_session(t); + push_system_line(t, "(switched to %s)", arg); + status_idle(t); + } + return 1; + } + + if (strcmp(cmd, "model") == 0) { + cst_agent_config_t *mcfg = cst_agent_config_mut(t->agent); + if (!mcfg) { + push_system_line(t, "Error: no agent config"); + return 1; + } + if (arg && arg[0]) { + snprintf(mcfg->model, sizeof(mcfg->model), "%s", arg); + push_system_line(t, "(model → %s)", mcfg->model); + set_status(t, mcfg->model); + redraw_all(t); + return 1; + } + char models[32][128]; + int n = cst_models_list(mcfg->model, models, 32); + if (n <= 0) { + push_system_line(t, "No models available (set CST_MODELS)"); + return 1; + } + int cur_idx = 0; + for (int i = 0; i < n; i++) { + if (strcmp(models[i], mcfg->model) == 0) { + cur_idx = i; + break; + } + } + int pick = picker_run(t, "Model", models, n, cur_idx); + if (pick < 0) { + set_status(t, "model pick cancelled"); + return 1; + } + snprintf(mcfg->model, sizeof(mcfg->model), "%s", models[pick]); + push_system_line(t, "(model → %s)", mcfg->model); + set_status(t, mcfg->model); + redraw_all(t); + return 1; + } + + if (strcmp(cmd, "compact") == 0 || strcmp(cmd, "summarize") == 0) { + char err[512] = {0}; + set_status(t, "compacting…"); + redraw_all(t); + doupdate(); + if (cst_agent_compact(t->agent, err, sizeof(err)) != 0) { + push_system_line(t, "Error: compact failed: %s", err[0] ? err : "?"); + set_status(t, err[0] ? err : "compact failed"); + } else { + reload_hist_from_session(t); + push_system_line(t, "(session compacted)"); + status_idle(t); + } + return 1; + } + + if (strcmp(cmd, "details") == 0) { + t->details_all = !t->details_all; + push_system_line(t, "tool details: %s", t->details_all ? "expanded" : "collapsed"); + set_status(t, t->details_all ? "details on" : "details off"); + return 1; + } + + if (strcmp(cmd, "help") == 0 || strcmp(cmd, "?") == 0) { + cmd_help(t); + return 1; + } + + if (strcmp(cmd, "quit") == 0 || strcmp(cmd, "q") == 0 || + strcmp(cmd, "exit") == 0) { + *should_quit = 1; + return 1; + } + + push_system_line(t, "Unknown command: /%s (try /help)", cmd); + set_status(t, "unknown slash command"); + return 1; +} + +static void toggle_think(tui_t *t) { + int idx = t->focus_think_idx; + if (idx < 0) { + for (int i = t->hist.n - 1; i >= 0; i--) { + if (t->hist.items[i].kind == BLK_THINK) { + idx = i; + break; + } + } + } + if (idx < 0 || idx >= t->hist.n) + return; + block_t *b = &t->hist.items[idx]; + if (b->kind != BLK_THINK) + return; + b->expanded = !b->expanded; + /* Keep timed title (Thought · Ns) if already set. */ + if (b->expanded && (!b->title || !b->title[0])) + block_set_title(b, "Thought"); + t->focus_think_idx = idx; + draw_log(t); +} + +static void toggle_last_tool(tui_t *t) { + int idx = t->last_tool_idx; + if (idx < 0 || idx >= t->hist.n) { + for (int i = t->hist.n - 1; i >= 0; i--) { + if (t->hist.items[i].kind == BLK_TOOL) { + idx = i; + break; + } + } + } + if (idx < 0 || idx >= t->hist.n) + return; + block_t *b = &t->hist.items[idx]; + if (b->kind != BLK_TOOL) + return; + b->expanded = !b->expanded; + t->last_tool_idx = idx; + draw_log(t); +} + +/* --- turn worker ---------------------------------------------------------- */ + +typedef struct { + tui_t *t; + char *prompt; +} turn_arg_t; + +static void *turn_thread(void *arg) { + turn_arg_t *a = arg; + tui_t *t = a->t; + cst_agent_hooks_t hooks = {0}; + hooks.on_status = on_status; + hooks.on_assistant_delta = on_assistant_delta; + hooks.on_think_delta = on_think_delta; + hooks.on_tool_event = on_tool_event; + hooks.on_permission = on_permission; + hooks.userdata = t; + + char err[512]; + err[0] = 0; + int rc = cst_agent_run_turn_ex(t->agent, a->prompt, &hooks, err, sizeof(err)); + + tui_lock(t); + t->turn_rc = rc; + snprintf(t->turn_err, sizeof(t->turn_err), "%s", err); + collapse_open_think(t); + if (t->as_live) + live_seal(t); + for (int i = 0; i < t->hist.n; i++) { + if (t->hist.items[i].kind == BLK_TOOL) + attach_tool_output_from_session(t, &t->hist.items[i]); + } + t->turn_done = 1; + t->busy = 0; + tui_mark_dirty(t); + tui_unlock(t); + + free(a->prompt); + free(a); + return NULL; +} + +static void request_cancel_ui(tui_t *t) { + cst_agent_request_cancel(t->agent); + set_status(t, "cancelling…"); + draw_status(t); +} + +/* Owns prompt. Returns 0 ok, -1 fail, -2 quit. */ +static int start_turn(tui_t *t, char *prompt) { + if (!prompt) + return -1; + if (strcmp(prompt, ":q") == 0 || strcmp(prompt, "quit") == 0 || + strcmp(prompt, ":quit") == 0) { + free(prompt); + return -2; + } + if (prompt[0] == '/') { + int should_quit = 0; + handle_slash(t, prompt, &should_quit); + free(prompt); + t->log_scroll = 0; + redraw_all(t); + return should_quit ? -2 : 0; + } + + prompt_hist_push(t, prompt); + + block_t *ub = blocks_push(&t->hist); + if (ub) { + ub->kind = BLK_USER; + block_set_title(ub, "You"); + block_set_body(ub, prompt, strlen(prompt)); + ub->expanded = 1; + } + t->think_idx = -1; + t->log_scroll = 0; + set_status(t, "streaming…"); + redraw_all(t); + + turn_arg_t *ta = calloc(1, sizeof(*ta)); + if (!ta) { + free(prompt); + return -1; + } + ta->t = t; + ta->prompt = prompt; + t->busy = 1; + t->turn_done = 0; + pthread_t th; + if (pthread_create(&th, NULL, turn_thread, ta) != 0) { + t->busy = 0; + free(prompt); + free(ta); + push_system_line(t, "Error: failed to start turn thread"); + status_idle(t); + redraw_all(t); + return -1; + } + pthread_detach(th); + return 0; +} + +static int try_start_queued(tui_t *t) { + if (t->busy || t->queue_n <= 0) + return 0; + char *next = queue_pop(t); + if (!next) + return 0; + return start_turn(t, next); +} + +static int submit_input(tui_t *t, int busy) { + if (t->in_len == 0) { + if (!busy && t->queue_n > 0) + return try_start_queued(t); + if (!busy) + toggle_last_tool(t); + return 0; + } + + char *prompt = strdup(t->inbuf); + t->in_len = 0; + t->inbuf[0] = 0; + t->hist_browse = -1; + layout_maybe_grow_input(t); + if (!prompt) + return 0; + + if (busy) { + if (queue_push(t, prompt) != 0) { + push_system_line(t, "(queue full — max %d)", QUEUE_CAP); + set_status(t, "queue full"); + free(prompt); + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "queued (%d)", t->queue_n); + set_status(t, msg); + free(prompt); + } + draw_status(t); + return 0; + } + return start_turn(t, prompt); +} + +/* --- main loop ------------------------------------------------------------ */ + +int cst_tui_run(cst_agent_t *agent) { + if (!setlocale(LC_ALL, "")) + setlocale(LC_ALL, "C.UTF-8"); + + initscr(); + cbreak(); + noecho(); + keypad(stdscr, TRUE); + curs_set(1); + theme_init(); + + tui_t t; + memset(&t, 0, sizeof(t)); + t.agent = agent; + t.think_idx = -1; + t.last_tool_idx = -1; + t.focus_think_idx = -1; + t.hist_browse = -1; + pthread_mutex_init(&t.mu, NULL); + blocks_init(&t.hist); + if (!t.hist.items) { + endwin(); + return -1; + } + + layout(&t); + reload_hist_from_session(&t); + status_idle(&t); + redraw_all(&t); + wtimeout(t.input, 80); + + for (;;) { + tui_lock(&t); + int dirty = t.dirty; + int turn_done = t.turn_done; + int turn_rc = t.turn_rc; + char turn_err[512]; + snprintf(turn_err, sizeof(turn_err), "%s", t.turn_err); + int perm_ask = t.perm_ask; + int busy = t.busy; + t.dirty = 0; + if (turn_done) + t.turn_done = 0; + tui_unlock(&t); + + if (dirty) + redraw_all(&t); + + if (turn_done) { + tui_lock(&t); + if (turn_rc != 0) { + push_system_line(&t, "Error: %s", turn_err[0] ? turn_err : "?"); + set_status_locked(&t, turn_err[0] ? turn_err : "turn failed"); + } else { + status_idle(&t); + } + t.log_scroll = 0; + tui_unlock(&t); + redraw_all(&t); + if (try_start_queued(&t) == -2) + break; + continue; + } + + if (perm_ask) { + t.awaiting_perm = 1; + draw_status(&t); + wtimeout(t.input, -1); + for (;;) { + wint_t wch = 0; + int rc = wget_wch(t.input, &wch); + if (rc == ERR) + continue; + if (rc == KEY_CODE_YES && wch == KEY_RESIZE) { + layout(&t); + wtimeout(t.input, -1); + redraw_all(&t); + continue; + } + if (wch == L'y' || wch == L'Y') { + tui_lock(&t); + t.perm_ans = 1; + tui_unlock(&t); + break; + } + if (wch == L'n' || wch == L'N') { + tui_lock(&t); + t.perm_ans = 0; + tui_unlock(&t); + break; + } + if (wch == L'a' || wch == L'A') { + tui_lock(&t); + perm_set_always(&t, t.perm_tool); + t.perm_ans = 1; + tui_unlock(&t); + break; + } + } + wtimeout(t.input, 80); + t.awaiting_perm = 0; + continue; + } + + wint_t wch = 0; + int rc = wget_wch(t.input, &wch); + if (rc == ERR) + continue; + + if (rc == KEY_CODE_YES) { + if (wch == KEY_RESIZE) { + layout(&t); + wtimeout(t.input, 80); + redraw_all(&t); + continue; + } + if (wch == KEY_MOUSE) { + MEVENT ev; + if (getmouse(&ev) == OK) { + if (ev.bstate & BUTTON4_PRESSED) { + t.log_scroll += SCROLL_SPEED; + draw_log(&t); + } else if (ev.bstate & BUTTON5_PRESSED) { + t.log_scroll -= SCROLL_SPEED; + if (t.log_scroll < 0) + t.log_scroll = 0; + draw_log(&t); + } + } + continue; + } + if (wch == KEY_PPAGE) { + t.log_scroll += (t.rows / 2); + draw_log(&t); + continue; + } + if (wch == KEY_NPAGE) { + t.log_scroll -= (t.rows / 2); + if (t.log_scroll < 0) + t.log_scroll = 0; + draw_log(&t); + continue; + } + if (wch == KEY_UP) { + if (t.prompt_hist_n <= 0) + continue; + int idx = t.hist_browse; + if (idx < 0) + idx = t.prompt_hist_n - 1; + else if (idx > 0) + idx--; + hist_load_into_input(&t, idx); + layout_maybe_grow_input(&t); + continue; + } + if (wch == KEY_DOWN) { + if (t.hist_browse < 0) + continue; + if (t.hist_browse + 1 >= t.prompt_hist_n) { + t.in_len = 0; + t.inbuf[0] = 0; + t.hist_browse = -1; + } else { + hist_load_into_input(&t, t.hist_browse + 1); + } + layout_maybe_grow_input(&t); + continue; + } + if (wch == KEY_BACKSPACE) { + size_t plen = utf8_prev_len(t.inbuf, t.in_len); + if (plen) { + t.in_len -= plen; + t.inbuf[t.in_len] = 0; + t.hist_browse = -1; + layout_maybe_grow_input(&t); + } + continue; + } + /* Enter (keypad) → send */ + if (wch == KEY_ENTER) { + int st = submit_input(&t, busy); + if (st == -2) + break; + continue; + } + continue; + } + + /* Ctrl+C */ + if (wch == 3) { + if (busy) { + request_cancel_ui(&t); + continue; + } + if (t.in_len == 0) + break; + t.in_len = 0; + t.inbuf[0] = 0; + t.hist_browse = -1; + layout_maybe_grow_input(&t); + continue; + } + + /* Ctrl+U clear line */ + if (wch == 21) { + t.in_len = 0; + t.inbuf[0] = 0; + t.hist_browse = -1; + layout_maybe_grow_input(&t); + continue; + } + + /* Esc */ + if (wch == 27) { + if (t.awaiting_perm) + continue; + if (busy) { + request_cancel_ui(&t); + continue; + } + if (t.in_len == 0) + break; + t.in_len = 0; + t.inbuf[0] = 0; + t.hist_browse = -1; + layout_maybe_grow_input(&t); + continue; + } + + /* Send on Enter: KEY_ENTER already handled; CR/LF also send (common). + * Newline insert: Ctrl+N (14). Ctrl+J==LF usually collides with Enter. */ + if (wch == 10 || wch == 13) { + int st = submit_input(&t, busy); + if (st == -2) + break; + continue; + } + if (wch == 14) { + input_put_byte(&t, '\n'); + layout_maybe_grow_input(&t); + continue; + } + + if ((wch == L'\t' || (!busy && wch == L't')) && t.in_len == 0) { + toggle_think(&t); + continue; + } + + if (wch == 127 || wch == 8) { + size_t plen = utf8_prev_len(t.inbuf, t.in_len); + if (plen) { + t.in_len -= plen; + t.inbuf[t.in_len] = 0; + t.hist_browse = -1; + layout_maybe_grow_input(&t); + } + continue; + } + + if (iswprint(wch) || wcwidth((wchar_t)wch) > 0) { + input_put_wc(&t, (wchar_t)wch); + layout_maybe_grow_input(&t); + } + } + + if (t.busy) { + cst_agent_request_cancel(t.agent); + for (int i = 0; i < 50 && t.busy; i++) + usleep(100000); + } + + queue_free_all(&t); + for (int i = 0; i < t.prompt_hist_n; i++) + free(t.prompt_hist[i]); + + live_end_cleanup(&t); + if (t.header) + delwin(t.header); + if (t.logw) + delwin(t.logw); + if (t.status) + delwin(t.status); + if (t.input) + delwin(t.input); + blocks_free(&t.hist); + pthread_mutex_destroy(&t.mu); + endwin(); + return 0; +} diff --git a/src/tui_stub.c b/src/tui_stub.c new file mode 100644 index 0000000..6a1f197 --- /dev/null +++ b/src/tui_stub.c @@ -0,0 +1,13 @@ +#include "cst_agent.h" + +#include + +#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 diff --git a/tests/test_ctl_frontier.c b/tests/test_ctl_frontier.c new file mode 100644 index 0000000..1f0c573 --- /dev/null +++ b/tests/test_ctl_frontier.c @@ -0,0 +1,156 @@ +#include "ctl.h" + +#include +#include +#include + +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; +} diff --git a/tests/test_json_baseline.c b/tests/test_json_baseline.c new file mode 100644 index 0000000..7df351b --- /dev/null +++ b/tests/test_json_baseline.c @@ -0,0 +1,55 @@ +#include "cst_agent.h" + +#include +#include + +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; +} diff --git a/tests/test_mm_tool.c b/tests/test_mm_tool.c new file mode 100644 index 0000000..c3ee301 --- /dev/null +++ b/tests/test_mm_tool.c @@ -0,0 +1,65 @@ +#include "mm_tool.h" + +#include +#include + +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" + "\n" + "invoke read_file\n" + "./README.md\n" + "\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 = "\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; +} diff --git a/tests/test_replay_starvation.c b/tests/test_replay_starvation.c new file mode 100644 index 0000000..43847fd --- /dev/null +++ b/tests/test_replay_starvation.c @@ -0,0 +1,102 @@ +#include "ctl.h" +#include "cst_agent.h" + +#include +#include +#include + +/* + * 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; +} diff --git a/third_party/busybox/LICENSE.busybox b/third_party/busybox/LICENSE.busybox new file mode 100644 index 0000000..aa3aaca --- /dev/null +++ b/third_party/busybox/LICENSE.busybox @@ -0,0 +1,2 @@ +BusyBox is GPLv2. Source: https://github.com/rmyorston/busybox-w32 +Upstream BusyBox: https://github.com/mirror/busybox diff --git a/third_party/busybox/README.md b/third_party/busybox/README.md new file mode 100644 index 0000000..ca5c16e --- /dev/null +++ b/third_party/busybox/README.md @@ -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 `/busybox`, then +`$PREFIX/bin/sh` (Termux), then `/bin/sh`. diff --git a/vendor/sashidown/Makefile b/vendor/sashidown/Makefile new file mode 100644 index 0000000..ad3c3c9 --- /dev/null +++ b/vendor/sashidown/Makefile @@ -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) diff --git a/vendor/sashidown/include/sashidown.h b/vendor/sashidown/include/sashidown.h new file mode 100644 index 0000000..f43c1f9 --- /dev/null +++ b/vendor/sashidown/include/sashidown.h @@ -0,0 +1,64 @@ +#ifndef SASHIDOWN_H +#define SASHIDOWN_H + +#include + +#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 */ diff --git a/vendor/sashidown/src/ansi_style.c b/vendor/sashidown/src/ansi_style.c new file mode 100644 index 0000000..eaffb8d --- /dev/null +++ b/vendor/sashidown/src/ansi_style.c @@ -0,0 +1,104 @@ +#include "ansi_style.h" +#include + +/* 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; +} diff --git a/vendor/sashidown/src/ansi_style.h b/vendor/sashidown/src/ansi_style.h new file mode 100644 index 0000000..5c9e7dd --- /dev/null +++ b/vendor/sashidown/src/ansi_style.h @@ -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 diff --git a/vendor/sashidown/src/block_scan.c b/vendor/sashidown/src/block_scan.c new file mode 100644 index 0000000..c03944c --- /dev/null +++ b/vendor/sashidown/src/block_scan.c @@ -0,0 +1,337 @@ +#include "block_scan.h" +#include + +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; +} diff --git a/vendor/sashidown/src/block_scan.h b/vendor/sashidown/src/block_scan.h new file mode 100644 index 0000000..8c06ece --- /dev/null +++ b/vendor/sashidown/src/block_scan.h @@ -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 +#include +#include + +#ifdef __SSE2__ +#include +#elif defined(__ARM_NEON) || defined(__aarch64__) +#include +#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 */ diff --git a/vendor/sashidown/src/compat_cmark.c b/vendor/sashidown/src/compat_cmark.c new file mode 100644 index 0000000..a38a137 --- /dev/null +++ b/vendor/sashidown/src/compat_cmark.c @@ -0,0 +1,44 @@ +#include "sashidown.h" +#include +#include +#include + +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); +} diff --git a/vendor/sashidown/src/fsm.c b/vendor/sashidown/src/fsm.c new file mode 100644 index 0000000..57853ba --- /dev/null +++ b/vendor/sashidown/src/fsm.c @@ -0,0 +1,110 @@ +#include "fsm.h" +#include + +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; +} diff --git a/vendor/sashidown/src/fsm.h b/vendor/sashidown/src/fsm.h new file mode 100644 index 0000000..188c301 --- /dev/null +++ b/vendor/sashidown/src/fsm.h @@ -0,0 +1,31 @@ +#ifndef MD_FSM_H +#define MD_FSM_H + +#include +#include +#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 */ diff --git a/vendor/sashidown/src/line_index.c b/vendor/sashidown/src/line_index.c new file mode 100644 index 0000000..58bc573 --- /dev/null +++ b/vendor/sashidown/src/line_index.c @@ -0,0 +1,184 @@ +#include "line_index.h" +#include +#include + +#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; +} diff --git a/vendor/sashidown/src/line_index.h b/vendor/sashidown/src/line_index.h new file mode 100644 index 0000000..b9ebad1 --- /dev/null +++ b/vendor/sashidown/src/line_index.h @@ -0,0 +1,40 @@ +#ifndef MD_LINE_INDEX_H +#define MD_LINE_INDEX_H + +#include +#include + +#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 */ diff --git a/vendor/sashidown/src/render.c b/vendor/sashidown/src/render.c new file mode 100644 index 0000000..66ffe3b --- /dev/null +++ b/vendor/sashidown/src/render.c @@ -0,0 +1,1202 @@ +#include "fsm.h" +#include "table_stats.h" +#include "line_index.h" +#include "block_scan.h" +#include "render.h" +#include +#ifdef __SSE2__ +#include +#elif defined(__ARM_NEON) || defined(__aarch64__) +#include +#endif + +static void html_escape(sashidown_ctx_t *ctx, const char *s, size_t n) +{ + size_t i = 0; +#ifdef __SSE2__ + const __m128i m_amp = _mm_set1_epi8('&'); + const __m128i m_lt = _mm_set1_epi8('<'); + const __m128i m_gt = _mm_set1_epi8('>'); + const __m128i m_quot = _mm_set1_epi8('"'); + while (i + 16 <= n) { + __m128i v = _mm_loadu_si128((const __m128i *)(s + i)); + __m128i 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_quot)); + int mask = _mm_movemask_epi8(hit); + if (mask) { + size_t pos = (unsigned)__builtin_ctz(mask); + if (pos > 0) + fsm_append_len(ctx, s + i, pos); + i += pos; + break; + } + fsm_append_len(ctx, s + i, 16); + i += 16; + } +#elif defined(__ARM_NEON) || defined(__aarch64__) + const uint8x16_t m_amp = vdupq_n_u8('&'); + const uint8x16_t m_lt = vdupq_n_u8('<'); + const uint8x16_t m_gt = vdupq_n_u8('>'); + const uint8x16_t m_quot = vdupq_n_u8('"'); + while (i + 16 <= n) { + uint8x16_t v = vld1q_u8((const uint8_t *)(s + i)); + uint8x16_t hit = vceqq_u8(v, m_amp); + hit = vorrq_u8(hit, vceqq_u8(v, m_lt)); + hit = vorrq_u8(hit, vceqq_u8(v, m_gt)); + hit = vorrq_u8(hit, vceqq_u8(v, m_quot)); + if (vmaxvq_u8(hit)) { + uint8_t lanes[16]; + vst1q_u8(lanes, hit); + size_t pos = 0; + while (pos < 16 && !lanes[pos]) pos++; + if (pos > 0) + fsm_append_len(ctx, s + i, pos); + i += pos; + break; + } + fsm_append_len(ctx, s + i, 16); + i += 16; + } +#endif + while (i < n) { + size_t j = i; + while (j < n) { + unsigned char c = (unsigned char)s[j]; + if (unlikely(CH_HTML_ESCAPE[c])) + 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 '>': fsm_append_lit(ctx, ">"); break; + case '"': fsm_append_lit(ctx, """); break; + default: fsm_append(ctx, s[j]); break; + } + i = j + 1; + } +} + +typedef struct { + uint8_t bold; + uint8_t italic; + uint8_t code; + uint8_t latex; + uint8_t strike; +} inline_state_t; + +static int render_inline_range(sashidown_ctx_t *ctx, const char *input, size_t len, + size_t start, size_t end, int escape_html, + inline_state_t *st, int flush); + +static void inline_flush_open(sashidown_ctx_t *ctx, inline_state_t *st) +{ + if (st->bold) { fsm_append_lit(ctx, ""); st->bold = 0; } + if (st->italic) { fsm_append_lit(ctx, ""); st->italic = 0; } + if (st->code) { fsm_append_lit(ctx, ""); st->code = 0; } + if (st->strike) { fsm_append_lit(ctx, ""); st->strike = 0; } +} + +static void inline_reset(inline_state_t *st) +{ + st->bold = st->italic = st->code = st->latex = st->strike = 0; +} + +static void inline_load_from_state(sashidown_render_state_t *rs, inline_state_t *st) +{ + st->bold = rs->inline_bold; + st->italic = rs->inline_italic; + st->code = rs->inline_code; + st->latex = rs->inline_latex; + st->strike = rs->inline_strike; +} + +static void inline_store_to_state(sashidown_render_state_t *rs, const inline_state_t *st) +{ + rs->inline_bold = st->bold; + rs->inline_italic = st->italic; + rs->inline_code = st->code; + rs->inline_latex = st->latex; + rs->inline_strike = st->strike; +} + +static void url_attr_escape(sashidown_ctx_t *ctx, const char *s, size_t n) +{ + static const char HEX[] = "0123456789ABCDEF"; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s[i]; + if (c >= 0x80) { + char hex[3] = {'%', HEX[c >> 4], HEX[c & 0xF]}; + fsm_append_len(ctx, hex, 3); + } else if (unlikely(CH_HTML_ESCAPE[c])) { + switch (c) { + case '&': fsm_append_lit(ctx, "&"); break; + case '<': fsm_append_lit(ctx, "<"); break; + case '>': fsm_append_lit(ctx, ">"); break; + case '"': fsm_append_lit(ctx, """); break; + default: fsm_append(ctx, (char)c); break; + } + } else { + fsm_append(ctx, (char)c); + } + } +} + +static int render_autolink(sashidown_ctx_t *ctx, const char *input, size_t end, + size_t *pos) +{ + size_t i = *pos; + if (i >= end || input[i] != '<') + return 0; + size_t start = i + 1; + if (start + 7 > end) /* http:// minimum-ish */ + return 0; + int is_url = (start + 7 <= end && memcmp(input + start, "http://", 7) == 0) || + (start + 8 <= end && memcmp(input + start, "https://", 8) == 0) || + (start + 7 <= end && memcmp(input + start, "mailto:", 7) == 0) || + (start + 6 <= end && memcmp(input + start, "ftp://", 6) == 0); + /* also allow bare mailto style: contains @ and no spaces */ + size_t j = start; + while (j < end && input[j] != '>' && input[j] != ' ' && + input[j] != '\n' && input[j] != '\r') + j++; + if (j >= end || input[j] != '>' || j == start) + return 0; + if (!is_url) { + int has_at = 0; + for (size_t k = start; k < j; k++) { + if (input[k] == '@') has_at = 1; + if (input[k] == '<' ) return 0; + } + if (!has_at) + return 0; + } + fsm_append_lit(ctx, ""); + html_escape(ctx, input + start, j - start); + fsm_append_lit(ctx, ""); + *pos = j + 1; + return 1; +} + +static int render_link(sashidown_ctx_t *ctx, 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++; + while (i < len && (input[i] == ' ' || input[i] == '\t')) i++; + + size_t url_start = i; + size_t url_end; + if (i < len && input[i] == '<') { + url_start = ++i; + i = block_scan_until_byte(input, len, i, '>'); + if (i >= len) return 0; + url_end = i; + i++; + } else { + while (i < len && input[i] != ')' && input[i] != ' ' && + input[i] != '\t' && input[i] != '\n' && input[i] != '"') + i++; + url_end = i; + } + while (i < len && (input[i] == ' ' || input[i] == '\t')) i++; + + size_t title_start = 0, title_end = 0; + int has_title = 0; + if (i < len && input[i] == '"') { + title_start = ++i; + i = block_scan_until_byte(input, len, i, '"'); + if (i >= len) return 0; + title_end = i; + has_title = 1; + i++; + while (i < len && (input[i] == ' ' || input[i] == '\t')) i++; + } + if (i >= len || input[i] != ')') return 0; + + if (is_image) { + fsm_append_lit(ctx, "\"");"); + } else { + fsm_append_lit(ctx, ""); + { + inline_state_t link_st = {0, 0, 0, 0, 0}; + render_inline_range(ctx, input, len, text_start, text_end, 1, &link_st, 1); + } + fsm_append_lit(ctx, ""); + } + *pos = i + 1; + return 1; +} + +static int render_inline_range(sashidown_ctx_t *ctx, const char *input, size_t len, + size_t start, size_t end, int escape_html, + inline_state_t *st, int flush) +{ + 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, '$'); + 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) { + fsm_append_lit(ctx, ""); + st->code = 0; + i = j + 1; + } else { + i = j; + } + continue; + } + + unsigned char ch = (unsigned char)input[i]; + + if (likely((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; + } + } + + { + static const void *dispatch[256] = { + ['$'] = &&lbl_dollar, + ['!'] = &&lbl_bang, + ['['] = &&lbl_bracket, + ['*'] = &&lbl_star, + ['_'] = &&lbl_underscore, + ['`'] = &&lbl_backtick, + ['~'] = &&lbl_tilde, + ['\\'] = &&lbl_escape, + ['&'] = &&lbl_html, + ['<'] = &&lbl_lt, + ['>'] = &&lbl_html, + }; + const void *target = dispatch[ch]; + if (target) goto *target; + goto lbl_default; + + lbl_dollar: + if (i + 1 < end && input[i + 1] == '$') { + size_t j = block_scan_until_2byte(input, end, i + 2, '$', '$'); + if (j < end) j += 2; + fsm_append_len(ctx, input + i, j - i); + i = j; + continue; + } + fsm_append(ctx, '$'); + st->latex = 1; + i++; + continue; + + lbl_bang: + if (i + 1 < end && input[i + 1] == '[') { + if (render_link(ctx, input, len, &i, 1)) + continue; + } + goto lbl_default; + + lbl_bracket: + if (render_link(ctx, input, len, &i, 0)) + continue; + goto lbl_default; + + lbl_star: + if (i + 1 < end && input[i + 1] == '*') { + if (st->bold) { + fsm_append_lit(ctx, ""); + st->bold = 0; + } else { + fsm_append_lit(ctx, ""); + st->bold = 1; + } + i += 2; + continue; + } + /* Inside **…, a lone * is literal (e.g. CCF A*). */ + if (st->bold) { + fsm_append(ctx, '*'); + i++; + continue; + } + goto lbl_emph; + + lbl_underscore: + if (i + 1 < end && input[i + 1] == '_') { + if (st->bold) { + fsm_append_lit(ctx, ""); + st->bold = 0; + } else { + fsm_append_lit(ctx, ""); + st->bold = 1; + } + i += 2; + continue; + } + goto lbl_emph; + + lbl_emph: + if (st->italic) { + fsm_append_lit(ctx, ""); + st->italic = 0; + } else { + fsm_append_lit(ctx, ""); + st->italic = 1; + } + i++; + continue; + + lbl_tilde: + if (i + 1 < end && input[i + 1] == '~') { + if (st->strike) { + fsm_append_lit(ctx, ""); + st->strike = 0; + } else { + fsm_append_lit(ctx, ""); + st->strike = 1; + } + i += 2; + continue; + } + goto lbl_default; + + lbl_escape: + if (i + 1 < end) { + unsigned char next = (unsigned char)input[i + 1]; + /* CommonMark: any ASCII punctuation can be escaped */ + if ((next >= 0x21 && next <= 0x2F) || + (next >= 0x3A && next <= 0x40) || + (next >= 0x5B && next <= 0x60) || + (next >= 0x7B && next <= 0x7E)) { + if (escape_html && CH_HTML_ESCAPE[next]) { + switch (next) { + case '&': fsm_append_lit(ctx, "&"); break; + case '<': fsm_append_lit(ctx, "<"); break; + case '>': fsm_append_lit(ctx, ">"); break; + case '"': fsm_append_lit(ctx, """); break; + default: fsm_append(ctx, (char)next); break; + } + } else { + fsm_append(ctx, (char)next); + } + i += 2; + continue; + } + } + fsm_append(ctx, '\\'); + i++; + continue; + + lbl_backtick: + fsm_append_lit(ctx, ""); + st->code = 1; + i++; + continue; + + lbl_lt: + if (render_autolink(ctx, input, end, &i)) + continue; + /* fallthrough to html escape */ + lbl_html: + if (escape_html) { + switch (ch) { + case '&': fsm_append_lit(ctx, "&"); break; + case '<': fsm_append_lit(ctx, "<"); break; + case '>': fsm_append_lit(ctx, ">"); break; + default: fsm_append(ctx, (char)ch); break; + } + i++; + continue; + } + /* fallthrough */ + + lbl_default: + fsm_append(ctx, (char)ch); + i++; + } + } + + if (flush) + inline_flush_open(ctx, st); + + return 0; +} + +static void render_table_row_at(sashidown_ctx_t *ctx, const char *input, + size_t len, size_t pos, size_t le, + int cols, int is_header); +static void table_close(sashidown_render_state_t *state, sashidown_ctx_t *ctx); +static int render_table_body_continue(sashidown_ctx_t *ctx, + sashidown_render_state_t *state, + const char *input, size_t len, + size_t *pos_io, size_t stop, int is_final); +static int try_open_table(sashidown_ctx_t *ctx, sashidown_render_state_t *state, + block_env_t *env, const char *input, size_t len, + size_t pos, size_t le, size_t stop, int is_final, + size_t *pos_out); +static int render_codeblock(sashidown_ctx_t *ctx, const char *input, size_t len, + size_t *pos, const line_info_t *open_li, + size_t stop, int is_final, + sashidown_render_state_t *state); +static int render_latex_block_multiline(sashidown_ctx_t *ctx, const char *input, + size_t len, size_t *pos, size_t stop, + int is_final, sashidown_render_state_t *state); + +static int block_line_is_blank(const char *input, size_t len, size_t pos, size_t le) +{ + (void)len; + return pos == le || (pos + 1 == le && input[pos] == '\r'); +} + +static int block_peek_continues_list(sashidown_ctx_t *ctx, const char *input, size_t len, + size_t pos, size_t limit) +{ + while (pos < limit) { + size_t le = block_line_end(ctx, input, len, pos); + if (block_line_is_blank(input, len, pos, le)) { + pos = block_skip_line(ctx, input, len, pos); + continue; + } + line_info_t nli = block_classify_line(ctx, input, len, pos, le); + return nli.kind == LK_UL || nli.kind == LK_OL; + } + return 0; +} + +static void block_close_lists(block_env_t *env, sashidown_ctx_t *ctx) +{ + if (env->ul) { fsm_append_lit(ctx, "\n"); env->ul = 0; } + if (env->ol) { fsm_append_lit(ctx, "\n"); env->ol = 0; } +} + +static void block_close_para(block_env_t *env, sashidown_ctx_t *ctx, + sashidown_render_state_t *rs) +{ + if (env->para) { + inline_state_t ist; + inline_load_from_state(rs, &ist); + inline_flush_open(ctx, &ist); + fsm_append_lit(ctx, "

\n"); + env->para = 0; + inline_reset(&ist); + inline_store_to_state(rs, &ist); + } +} + +static void block_close_all(block_env_t *env, sashidown_ctx_t *ctx, + sashidown_render_state_t *rs) +{ + if (!env->ul && !env->ol && !env->para && !env->bq) return; + block_close_lists(env, ctx); + block_close_para(env, ctx, rs); + if (env->bq) { fsm_append_lit(ctx, "\n"); env->bq = 0; } +} + +static void table_close(sashidown_render_state_t *state, sashidown_ctx_t *ctx) +{ + if (!state->in_table) + return; + fsm_append_lit(ctx, "\n\n"); + state->in_table = 0; + state->table_cols = 0; +} + +void sashidown_render_state_init(sashidown_render_state_t *state) +{ + if (state) + memset(state, 0, sizeof(*state)); +} + +/* Continue an open table: emit body rows until a terminator line (left unconsumed) + * or soft-stop. Returns 1 if still in_table waiting for more input. */ +static int render_table_body_continue(sashidown_ctx_t *ctx, + sashidown_render_state_t *state, + const char *input, size_t len, + size_t *pos_io, size_t stop, int is_final) +{ + size_t pos = *pos_io; + size_t limit = is_final ? len : stop; + int cols = state->table_cols; + + while (pos < limit) { + size_t le = block_line_end(ctx, input, len, pos); + int empty = (pos == le || (pos + 1 == le && input[pos] == '\r')); + if (empty) { + table_close(state, ctx); + *pos_io = pos; + return 0; + } + + size_t llen = le - pos; + if (!memchr(input + pos, '|', llen) || + table_is_separator_row(input + pos, llen)) { + table_close(state, ctx); + *pos_io = pos; + return 0; + } + + /* Line must lie fully inside the soft window (newline included). */ + if (!is_final) { + int complete = (le < len && input[le] == '\n' && le + 1 <= stop); + if (!complete) { + *pos_io = pos; + return 1; + } + } + + render_table_row_at(ctx, input, len, pos, le, cols, 0); + pos = block_skip_line(ctx, input, len, pos); + if (ctx->overflow) { + *pos_io = pos; + return 1; + } + } + + *pos_io = pos; + if (is_final) { + table_close(state, ctx); + return 0; + } + return state->in_table ? 1 : 0; +} + +/* Try to open a table at pos (line has '|'). + * Returns 1 if opened (pos_out after sep), 0 if not a table, + * -1 if need more input (defer, pos unchanged). */ +static int try_open_table(sashidown_ctx_t *ctx, sashidown_render_state_t *state, + block_env_t *env, const char *input, size_t len, + size_t pos, size_t le, size_t stop, int is_final, + size_t *pos_out) +{ + 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 is_final ? 0 : -1; + + size_t next = le + 1; + if (next >= len) + return is_final ? 0 : -1; + if (!is_final && next >= stop) + return -1; + + 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; + } + + if (!is_final && nle >= stop && (nle >= len || input[nle] != '\n')) { + block_line_seek(ctx, pos); + return -1; + } + + block_close_all(env, ctx, state); + state->pending_valid = 0; + state->para_hard_break = 0; + + fsm_append_lit(ctx, "\n\n"); + render_table_row_at(ctx, input, len, pos, le, cols, 1); + fsm_append_lit(ctx, "\n\n"); + if (ctx->overflow) { + /* Partial tags may be in the attempt buffer; stream rewinds state. + * Do not commit in_table or advance pos. */ + block_line_seek(ctx, pos); + *pos_out = pos; + return 1; + } + + state->in_table = 1; + state->table_cols = cols; + *pos_out = block_skip_line(ctx, input, len, next); + return 1; +} + +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) +{ + size_t pos = *pos_io; + block_env_t *env = &state->block; + + if (state->in_fence) { + render_codeblock(ctx, input, len, &pos, NULL, stop, is_final, state); + *pos_io = pos; + if (ctx->overflow) + return -1; + if (state->in_fence && !is_final && pos >= stop) + return 0; + } + + if (state->in_latex_block) { + render_latex_block_multiline(ctx, input, len, &pos, stop, is_final, state); + *pos_io = pos; + if (ctx->overflow) + return -1; + if (state->in_latex_block && !is_final && pos >= stop) + return 0; + } + + if (state->in_table) { + int waiting = render_table_body_continue(ctx, state, input, len, + &pos, stop, is_final); + *pos_io = pos; + if (ctx->overflow) + return -1; + if (waiting) + return 0; + /* Terminator line left at pos — fall through to normal dispatch. */ + } + + size_t limit = is_final ? len : stop; + + while (pos < limit) { + size_t le = 0; + line_info_t li; + int have_li = 0; + + if (state->pending_valid && state->pending_pos == pos) { + li = state->pending_li; + le = state->pending_le; + state->pending_valid = 0; + have_li = 1; + } + + if (!have_li) { + le = block_line_end(ctx, input, len, pos); + /* Empty lines: no table/setext probes. */ + if (block_line_is_blank(input, len, pos, le)) { + size_t after = block_skip_line(ctx, input, len, pos); + if (!is_final && (env->ul || env->ol) && + block_peek_continues_list(ctx, input, len, after, limit)) { + pos = after; + continue; + } + block_close_all(env, ctx, state); + state->para_hard_break = 0; + pos = after; + continue; + } + } + + /* Table open (scheme C): commit on header+sep, then body streams. */ + if (memchr(input + pos, '|', le - pos) != NULL) { + size_t newt = 0; + int tr = try_open_table(ctx, state, env, input, len, pos, le, + stop, is_final, &newt); + if (tr < 0) + break; + if (tr > 0) { + if (ctx->overflow) + goto done; + pos = newt; + block_line_seek(ctx, pos); + if (state->in_table) { + int waiting = render_table_body_continue(ctx, state, input, len, + &pos, stop, is_final); + if (ctx->overflow) + goto done; + if (waiting) + goto done; + } + continue; + } + } + + if (!have_li) { + li = block_classify_line(ctx, input, len, pos, le); + have_li = 1; + } + + /* Setext: only paragraph lines; peek next line without dirtying cursor. */ + if (li.kind == LK_PARA) { + line_info_t sli; + size_t send = 0; + int sr = block_try_setext(ctx, input, len, pos, le, stop, is_final, + &sli, &send); + if (sr < 0) + break; + if (sr > 0) { + block_close_all(env, ctx, state); + state->pending_valid = 0; + state->para_hard_break = 0; + { + int lvl = sli.heading_level; + switch (lvl) { + case 1: fsm_append_lit(ctx, "

"); break; + case 2: fsm_append_lit(ctx, "

"); break; + case 3: fsm_append_lit(ctx, "

"); break; + case 4: fsm_append_lit(ctx, "

"); break; + case 5: fsm_append_lit(ctx, "

"); break; + default: fsm_append_lit(ctx, "
"); break; + } + { + inline_state_t ist = {0, 0, 0, 0, 0}; + render_inline_range(ctx, input, len, sli.content_start, + sli.content_end, 1, &ist, 1); + } + switch (lvl) { + case 1: fsm_append_lit(ctx, "
\n"); break; + case 2: fsm_append_lit(ctx, "\n"); break; + case 3: fsm_append_lit(ctx, "\n"); break; + case 4: fsm_append_lit(ctx, "\n"); break; + case 5: fsm_append_lit(ctx, "\n"); break; + default: fsm_append_lit(ctx, "\n"); break; + } + } + pos = send; + block_line_seek(ctx, pos); + continue; + } + } + + switch (li.kind) { + case LK_EMPTY: { + size_t after = block_skip_line(ctx, input, len, pos); + if (!is_final && (env->ul || env->ol) && + block_peek_continues_list(ctx, input, len, after, limit)) { + pos = after; + break; + } + block_close_all(env, ctx, state); + state->para_hard_break = 0; + pos = after; + break; + } + + case LK_HR: + block_close_all(env, ctx, state); + state->para_hard_break = 0; + fsm_append_lit(ctx, "
\n"); + pos = block_skip_line(ctx, input, len, pos); + break; + + case LK_FENCE: + block_close_all(env, ctx, state); + state->para_hard_break = 0; + render_codeblock(ctx, input, len, &pos, &li, stop, is_final, state); + if (state->in_fence && !is_final && pos >= stop) + goto done; + break; + + case LK_LATEX_LINE: + block_close_all(env, ctx, state); + state->para_hard_break = 0; + fsm_append_lit(ctx, "

$$"); + html_escape(ctx, input + li.content_start, + li.content_end - li.content_start); + fsm_append_lit(ctx, "$$

\n"); + pos = block_skip_line(ctx, input, len, pos); + break; + + case LK_LATEX_BLOCK: + block_close_all(env, ctx, state); + state->para_hard_break = 0; + render_latex_block_multiline(ctx, input, len, &pos, stop, is_final, state); + if (state->in_latex_block && !is_final && pos >= stop) + goto done; + break; + + case LK_HEADING: + block_close_all(env, ctx, state); + state->para_hard_break = 0; + { + int lvl = li.heading_level; + size_t hend = (li.content_end > li.content_start) ? li.content_end : le; + switch (lvl) { + case 1: fsm_append_lit(ctx, "

"); break; + case 2: fsm_append_lit(ctx, "

"); break; + case 3: fsm_append_lit(ctx, "

"); break; + case 4: fsm_append_lit(ctx, "

"); break; + case 5: fsm_append_lit(ctx, "

"); break; + default: fsm_append_lit(ctx, "
"); break; + } + { + inline_state_t ist = {0, 0, 0, 0, 0}; + render_inline_range(ctx, input, len, li.content_start, hend, 1, &ist, 1); + } + switch (lvl) { + case 1: fsm_append_lit(ctx, "
\n"); break; + case 2: fsm_append_lit(ctx, "\n"); break; + case 3: fsm_append_lit(ctx, "\n"); break; + case 4: fsm_append_lit(ctx, "\n"); break; + case 5: fsm_append_lit(ctx, "\n"); break; + default: fsm_append_lit(ctx, "\n"); break; + } + } + pos = block_skip_line(ctx, input, len, pos); + break; + + case LK_BQ: + block_close_lists(env, ctx); + state->para_hard_break = 0; + if (!env->bq) { fsm_append_lit(ctx, "
\n"); env->bq = 1; } + fsm_append_lit(ctx, "

"); + { + inline_state_t ist = {0, 0, 0, 0, 0}; + render_inline_range(ctx, input, len, li.content_start, le, 1, &ist, 1); + } + fsm_append_lit(ctx, "

\n"); + pos = block_skip_line(ctx, input, len, pos); + break; + + case LK_UL: + if (env->ol) { fsm_append_lit(ctx, "\n"); env->ol = 0; } + if (!env->ul) { fsm_append_lit(ctx, "
    \n"); env->ul = 1; } + state->para_hard_break = 0; + fsm_append_lit(ctx, "
  • "); + if (li.task == 1) + fsm_append_lit(ctx, " "); + else if (li.task == 2) + fsm_append_lit(ctx, " "); + { + inline_state_t ist = {0, 0, 0, 0, 0}; + render_inline_range(ctx, input, len, li.content_start, le, 1, &ist, 1); + } + fsm_append_lit(ctx, "
  • \n"); + pos = block_skip_line(ctx, input, len, pos); + break; + + case LK_OL: + if (env->ul) { fsm_append_lit(ctx, "
\n"); env->ul = 0; } + if (!env->ol) { fsm_append_lit(ctx, "
    \n"); env->ol = 1; } + state->para_hard_break = 0; + fsm_append_lit(ctx, "
  1. "); + { + inline_state_t ist = {0, 0, 0, 0, 0}; + render_inline_range(ctx, input, len, li.content_start, le, 1, &ist, 1); + } + fsm_append_lit(ctx, "
  2. \n"); + pos = block_skip_line(ctx, input, len, pos); + break; + + case LK_PARA: + default: { + size_t content_end = le; + int hard = 0; + if (content_end > pos && input[content_end - 1] == '\r') + content_end--; + if (content_end >= pos + 1 && input[content_end - 1] == '\\') { + hard = 1; + content_end--; + } else if (content_end >= pos + 2 && + input[content_end - 1] == ' ' && + input[content_end - 2] == ' ') { + hard = 1; + content_end -= 2; + } + + if (env->bq) { fsm_append_lit(ctx, "
\n"); env->bq = 0; } + block_close_lists(env, ctx); + if (!env->para) { + fsm_append_lit(ctx, "

"); + env->para = 1; + } else if (!state->para_hard_break) { + fsm_append(ctx, '\n'); + } + { + inline_state_t ist; + inline_load_from_state(state, &ist); + render_inline_range(ctx, input, len, pos, content_end, 1, &ist, 0); + inline_store_to_state(state, &ist); + } + if (hard) { + fsm_append_lit(ctx, "
\n"); + state->para_hard_break = 1; + } else { + state->para_hard_break = 0; + } + pos = block_skip_line(ctx, input, len, pos); + if (pos >= limit) { + if (is_final) + block_close_para(env, ctx, state); + } else { + size_t nle = block_line_end(ctx, input, len, pos); + line_info_t nli = block_classify_line(ctx, input, len, pos, nle); + state->pending_valid = 1; + state->pending_pos = pos; + state->pending_le = nle; + state->pending_li = nli; + if (nli.kind != LK_PARA) + block_close_para(env, ctx, state); + } + break; + } + } + } + +done: + if (is_final) { + table_close(state, ctx); + block_close_all(env, ctx, state); + } + + *pos_io = pos; + return ctx->overflow ? -1 : 0; +} + +int sashidown_render(sashidown_ctx_t *ctx, const char *input, size_t len) +{ + sashidown_render_state_t state; + size_t pos = 0; + + sashidown_render_state_init(&state); + fsm_write_begin(ctx); + int r = sashidown_render_incremental(ctx, &state, input, len, &pos, len, 1); + fsm_write_end(ctx); + return r; +} + +static void trim_cell_bounds(const char *input, size_t *start, size_t *end) +{ + while (*start < *end && input[*start] == ' ') + (*start)++; + while (*end > *start && input[*end - 1] == ' ') + (*end)--; +} + +static void render_table_cell(sashidown_ctx_t *ctx, const char *input, + size_t len, size_t abs_start, size_t abs_end) +{ + trim_cell_bounds(input, &abs_start, &abs_end); + if (abs_end <= abs_start) + return; + /* One pass: plain SIMD scan + memcpy / inline FSM (no pre-probe). */ + { + inline_state_t ist = {0, 0, 0, 0, 0}; + render_inline_range(ctx, input, len, abs_start, abs_end, 1, &ist, 1); + } +} + +static void render_table_row_at(sashidown_ctx_t *ctx, const char *input, + size_t len, size_t pos, size_t le, + int cols, int is_header) +{ + fsm_append_lit(ctx, "

"); + 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++; + if (is_header) { + fsm_append_lit(ctx, ""); + } else { + fsm_append_lit(ctx, ""); + } + if (i < le && input[i] == '|') i++; + } + fsm_append_lit(ctx, "\n"); +} + +static int render_codeblock(sashidown_ctx_t *ctx, const char *input, size_t len, + size_t *pos, const line_info_t *open_li, + size_t stop, int is_final, + sashidown_render_state_t *state) +{ + if (!state->in_fence) { + int fence_len = open_li ? open_li->fence_len : state->fence_len; + char fence_char = open_li ? open_li->fence_char : state->fence_char; + size_t info_start = open_li ? open_li->info_start : 0; + size_t info_end = open_li ? open_li->info_end : 0; + + fsm_append_lit(ctx, "
 info_start) {
+            size_t s = info_start, e = info_start;
+            while (e < info_end && input[e] != ' ' && input[e] != '\t')
+                e++;
+            if (e > s) {
+                fsm_append_lit(ctx, " class=\"language-");
+                html_escape(ctx, input + s, e - s);
+                fsm_append_lit(ctx, "\"");
+            }
+        }
+        fsm_append(ctx, '>');
+        state->in_fence = 1;
+        state->fence_len = fence_len;
+        state->fence_char = fence_char;
+        state->fence_info_start = info_start;
+        state->fence_info_end = info_end;
+        *pos = block_skip_line(ctx, input, len, *pos);
+    }
+
+    size_t limit = is_final ? len : stop;
+
+    while (*pos < len) {
+        if (*pos >= limit && !is_final) {
+            return 0;
+        }
+
+        int fl = 0;
+        char fc = 0;
+        if (block_is_fence(input, len, *pos, &fl, &fc) &&
+            fc == state->fence_char && fl >= state->fence_len) {
+            /* Closing fence: info string must be empty (CommonMark). */
+            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);
+                fsm_append_lit(ctx, "
\n"); + state->in_fence = 0; + return 0; + } + } + size_t le = block_line_end(ctx, input, len, *pos); + html_escape(ctx, input + *pos, le - *pos); + if (le < len) fsm_append(ctx, '\n'); + *pos = block_skip_line(ctx, input, len, *pos); + } + + if (is_final && state->in_fence) { + fsm_append_lit(ctx, "\n"); + state->in_fence = 0; + } + return 0; +} + +static int render_latex_block_multiline(sashidown_ctx_t *ctx, const char *input, size_t len, + size_t *pos, size_t stop, int is_final, + sashidown_render_state_t *state) +{ + size_t i; + if (!state->in_latex_block) { + fsm_append_lit(ctx, "

$$"); + state->in_latex_block = 1; + i = *pos + 2; + } else { + i = *pos; + } + + size_t start = i; + size_t limit = is_final ? len : stop; + + while (i < len) { + if (!is_final && i >= limit) { + if (i > start) + fsm_append_len(ctx, input + start, i - start); + *pos = i; + return 0; + } + size_t scan_end = is_final ? len : limit; + if (i >= scan_end) { + if (i > start) + fsm_append_len(ctx, input + start, i - start); + *pos = i; + return 0; + } + const char *hitp = memchr(input + i, '$', scan_end - i); + if (!hitp) { + if (scan_end > start) + fsm_append_len(ctx, input + start, scan_end - start); + *pos = scan_end; + if (is_final && state->in_latex_block) { + fsm_append_lit(ctx, "$$

\n"); + state->in_latex_block = 0; + } + return 0; + } + i = (size_t)(hitp - input); + if (i + 1 < len && input[i + 1] == '$') { + /* Closing $$ must lie fully before soft-stop when incremental. */ + if (!is_final && i + 1 >= limit) { + if (limit > start) + fsm_append_len(ctx, input + start, limit - start); + *pos = limit; + return 0; + } + if (i > start) + fsm_append_len(ctx, input + start, i - start); + fsm_append_lit(ctx, "$$

\n"); + i += 2; + if (i < len && input[i] == '\n') + i++; + *pos = i; + state->in_latex_block = 0; + return 0; + } + i++; + } + + if (i > start) + fsm_append_len(ctx, input + start, i - start); + *pos = i; + + if (is_final && state->in_latex_block) { + fsm_append_lit(ctx, "$$

\n"); + state->in_latex_block = 0; + } + return 0; +} diff --git a/vendor/sashidown/src/render.h b/vendor/sashidown/src/render.h new file mode 100644 index 0000000..4d7433e --- /dev/null +++ b/vendor/sashidown/src/render.h @@ -0,0 +1,90 @@ +#ifndef MD_RENDER_H +#define MD_RENDER_H + +#include "sashidown.h" +#include +#include + +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

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 */ diff --git a/vendor/sashidown/src/render_ansi.c b/vendor/sashidown/src/render_ansi.c new file mode 100644 index 0000000..37cf413 --- /dev/null +++ b/vendor/sashidown/src/render_ansi.c @@ -0,0 +1,559 @@ +#include "render_ansi.h" +#include "ansi_style.h" +#include "block_scan.h" +#include "table_stats.h" +#include "fsm.h" +#include +#include + +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()); +} diff --git a/vendor/sashidown/src/render_ansi.h b/vendor/sashidown/src/render_ansi.h new file mode 100644 index 0000000..8828757 --- /dev/null +++ b/vendor/sashidown/src/render_ansi.h @@ -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 diff --git a/vendor/sashidown/src/render_json.c b/vendor/sashidown/src/render_json.c new file mode 100644 index 0000000..a7373cd --- /dev/null +++ b/vendor/sashidown/src/render_json.c @@ -0,0 +1,620 @@ +#include "render_json.h" +#include "block_scan.h" +#include "table_stats.h" +#include "fsm.h" +#include +#include + +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; +} diff --git a/vendor/sashidown/src/render_json.h b/vendor/sashidown/src/render_json.h new file mode 100644 index 0000000..30e4720 --- /dev/null +++ b/vendor/sashidown/src/render_json.h @@ -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 diff --git a/vendor/sashidown/src/sashidown.c b/vendor/sashidown/src/sashidown.c new file mode 100644 index 0000000..9e6fc55 --- /dev/null +++ b/vendor/sashidown/src/sashidown.c @@ -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 + +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; + } +} diff --git a/vendor/sashidown/src/stream.c b/vendor/sashidown/src/stream.c new file mode 100644 index 0000000..da3d99b --- /dev/null +++ b/vendor/sashidown/src/stream.c @@ -0,0 +1,364 @@ +#include "sashidown.h" +#include "line_index.h" +#include "fsm.h" +#include "render.h" +#include +#include + +#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; +} diff --git a/vendor/sashidown/src/table_stats.c b/vendor/sashidown/src/table_stats.c new file mode 100644 index 0000000..cd4ee7f --- /dev/null +++ b/vendor/sashidown/src/table_stats.c @@ -0,0 +1,137 @@ +#include "table_stats.h" +#include "line_index.h" +#include + +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); +} diff --git a/vendor/sashidown/src/table_stats.h b/vendor/sashidown/src/table_stats.h new file mode 100644 index 0000000..8566e3b --- /dev/null +++ b/vendor/sashidown/src/table_stats.h @@ -0,0 +1,32 @@ +#ifndef MD_TABLE_STATS_H +#define MD_TABLE_STATS_H + +#include "sashidown.h" +#include +#include + +#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 */

"); + render_table_cell(ctx, input, len, cs, i); + fsm_append_lit(ctx, ""); + render_table_cell(ctx, input, len, cs, i); + fsm_append_lit(ctx, "