Solo-C: a tiny coding agent in a single C file
main.c is the whole agent: a readline REPL that talks to any OpenAI-compatible /chat/completions endpoint and exposes exactly one tool, bash(command). Commands run through the user's shell with stdout and stderr merged, a hard timeout, and a process-group kill; the output and an exit marker go back to the model until it answers without calling a tool. make static-deps builds curl and c-ares from source into static-deps/, so `make static` can link a fully static binary on distros that get in the way: the distro libcurl.a drags in libpsl, brotli, zstd and nghttp2, whose static objects are GCC 14 LTO bytecode a newer linker cannot read, and a minimal system often ships no libcares.a at all. Two details worth keeping in mind when editing mkstaticdeps.sh. curl 8.22 takes --enable-ares=PREFIX rather than the older --with-cares, and it only warns about options it does not recognise, so the wrong spelling turns off async DNS in silence instead of failing. And link order is load-bearing: libcares.a has to come after libcurl.a or ares_* stays undefined.
This commit is contained in:
+75
@@ -0,0 +1,75 @@
|
||||
# Build artifacts.
|
||||
# Both names are produced by the Makefile targets `solo` and `static`.
|
||||
# Anchor on the root so a directory called solo is still tracked.
|
||||
/solo
|
||||
/solo-static
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
*.so.*
|
||||
|
||||
# Prebuilt libcurl.a for `make static`, regenerated by `make static-deps`
|
||||
# (see mkstaticdeps.sh). Large, and tied to the machine that built it.
|
||||
/static-deps/
|
||||
/curl-*/
|
||||
|
||||
# Per-directory session state: the live model parameters, the tool schema and
|
||||
# the full conversation transcript, which can contain secrets. Runtime data, so
|
||||
# it must never be committed.
|
||||
.solo_sess.json
|
||||
*.solo_sess.json
|
||||
|
||||
# Readline history, in case HOME or XDG is pointed here in some setups.
|
||||
.solo_history
|
||||
.history
|
||||
|
||||
# Scratch space the agent is told to use.
|
||||
/solo-tmp/
|
||||
/tmp/
|
||||
|
||||
# Editor and tooling droppings.
|
||||
*~
|
||||
*.bak
|
||||
*.orig
|
||||
*.rej
|
||||
*.swp
|
||||
*.swo
|
||||
.*.sw?
|
||||
.\#*
|
||||
\#*\#
|
||||
|
||||
# Vim.
|
||||
.vim/
|
||||
*.vim
|
||||
*.swo
|
||||
|
||||
# Emacs.
|
||||
*.elc
|
||||
*.symc
|
||||
.\#*
|
||||
\#*\#
|
||||
|
||||
# Compiled binaries and debug output from ad-hoc experiments.
|
||||
/core
|
||||
/core.*
|
||||
vgcore.*
|
||||
a.out
|
||||
gdb.core
|
||||
|
||||
# Coverage and profiling, if instrumentation is ever added.
|
||||
*.gcda
|
||||
*.gcno
|
||||
*.gcov
|
||||
*.profraw
|
||||
*.profdata
|
||||
|
||||
# OS cruft.
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Secrets and local overrides. Commit a .env.example instead.
|
||||
.env
|
||||
.env.local
|
||||
SOLO_API_KEY
|
||||
SOLO_SYSPROMPT.local
|
||||
@@ -0,0 +1,61 @@
|
||||
CC = gcc
|
||||
CFLAGS = -Wall -Wextra -O2 -std=c99 -march=native $(shell pkg-config --cflags libcurl json-c readline)
|
||||
LDLIBS = $(shell pkg-config --libs libcurl json-c readline)
|
||||
|
||||
# Fully static binary.
|
||||
#
|
||||
# The distro libcurl.a cannot be linked statically on Alpine: it pulls in
|
||||
# libpsl, libbrotli, libzstd and libnghttp2, whose static archives hold LTO
|
||||
# bytecode from GCC 14 that the installed GCC 15 cannot read ("bytecode
|
||||
# stream ... generated with LTO version 14.0 instead of the expected 15.1"),
|
||||
# so psl_*/Brotli*/ZSTD* never resolve. -fno-lto does not help; the objects
|
||||
# are bytecode, not C.
|
||||
#
|
||||
# static-deps/libcurl.a is curl built without those four libraries, leaving
|
||||
# only LTO-free static deps. Regenerate with: make static-deps
|
||||
#
|
||||
# c-ares is built from source by the same script, for two reasons: a minimal
|
||||
# system often has no libcares.a at all (Void's c-ares-devel ships the shared
|
||||
# library only), and where an archive does exist it can carry the same LTO
|
||||
# bytecode as the distro libcurl.a. Building it also pins the version.
|
||||
#
|
||||
# Archive order matters: libcurl.a pulls in ares_* symbols, so libcares.a has to
|
||||
# come after it on the link line.
|
||||
CURL_STATIC ?= static-deps/libcurl.a
|
||||
CARES_STATIC ?= static-deps/libcares.a
|
||||
STATIC_LIBS = $(CURL_STATIC) $(CARES_STATIC) -lssl -lcrypto -lz -lidn2 -lunistring \
|
||||
-lreadline -lncursesw -ljson-c -lm -ldl -pthread
|
||||
|
||||
solo: main.c
|
||||
$(CC) $(CFLAGS) -o solo main.c $(LDLIBS)
|
||||
strip solo
|
||||
|
||||
static: main.c
|
||||
@for f in $(CURL_STATIC) $(CARES_STATIC); do \
|
||||
test -f $$f || { echo "missing $$f - run: make static-deps"; exit 1; }; \
|
||||
done
|
||||
$(CC) $(CFLAGS) -fno-lto -static -o solo-static main.c $(STATIC_LIBS)
|
||||
strip solo-static
|
||||
|
||||
static-deps:
|
||||
sh ./mkstaticdeps.sh
|
||||
|
||||
# Rebuild both archives from scratch, ignoring what is already there.
|
||||
static-deps-force:
|
||||
FORCE=1 sh ./mkstaticdeps.sh
|
||||
|
||||
PREFIX ?= /usr/local
|
||||
BINDIR = $(PREFIX)/bin
|
||||
INSTALL ?= install
|
||||
|
||||
install: solo
|
||||
$(INSTALL) -d $(DESTDIR)$(BINDIR)
|
||||
$(INSTALL) -m 755 solo $(DESTDIR)$(BINDIR)/solo
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)$(BINDIR)/solo $(DESTDIR)$(BINDIR)/solo-static
|
||||
|
||||
clean:
|
||||
rm -f solo solo-static
|
||||
|
||||
.PHONY: clean static static-deps static-deps-force install uninstall
|
||||
@@ -0,0 +1,432 @@
|
||||
# Solo-C
|
||||
|
||||
A tiny coding agent in a single C file.
|
||||
|
||||
Solo-C is a port of the [Solo](https://solo.luxferre.top) coding agent: a
|
||||
read-eval-print loop that talks to any OpenAI-compatible chat-completions
|
||||
endpoint and gives the model exactly one tool, `bash`. The model writes a shell
|
||||
command, Solo-C runs it through your shell, feeds the combined output and exit
|
||||
code back, and repeats until the model answers without asking for a tool.
|
||||
|
||||
The whole client is one ~400-line file, `main.c`, with no third-party code
|
||||
beyond libcurl, json-c and readline.
|
||||
|
||||
## Requirements
|
||||
|
||||
Build-time:
|
||||
|
||||
- A C99 compiler (GCC or Clang; built and tested with GCC 14)
|
||||
- GNU make
|
||||
- `pkg-config`
|
||||
- libcurl, json-c and GNU readline development headers
|
||||
|
||||
Runtime (dynamic build): the matching shared libraries, plus a shell.
|
||||
|
||||
Install the dependencies with your package manager, for example:
|
||||
```
|
||||
# Debian / Ubuntu
|
||||
sudo apt install build-essential pkg-config libcurl4-openssl-dev libjson-c-dev libreadline-dev
|
||||
|
||||
# Alpine
|
||||
sudo apk add build-base pkgconf curl-dev json-c-dev readline-dev
|
||||
|
||||
# Void Linux
|
||||
sudo xbps-install -S gcc make pkg-config curl-devel json-c-devel readline-devel
|
||||
```
|
||||
`make` picks up the include and library flags through `pkg-config`, so there is
|
||||
nothing to configure by hand.
|
||||
|
||||
## Build
|
||||
```
|
||||
make
|
||||
```
|
||||
That produces a stripped dynamic binary named `solo` in the project directory.
|
||||
The default flags are: `-Wall -Wextra -O2 -std=c99 -march=native`.
|
||||
|
||||
Other targets:
|
||||
|
||||
make clean # remove solo and solo-static
|
||||
make install # install to $(PREFIX)/bin, default /usr/local/bin
|
||||
make uninstall # remove the installed binaries
|
||||
make static-deps # build the static curl and c-ares archives, see below
|
||||
|
||||
## Static build
|
||||
|
||||
`make static` links a fully static `solo-static`, useful for running the agent
|
||||
on a machine with no shared libraries, or from a rescue shell.
|
||||
|
||||
It does not work out of the box everywhere, for two reasons.
|
||||
|
||||
On Alpine and on any distro whose `libcurl.a` was built by a different GCC than
|
||||
yours, the distro archive pulls in libpsl, brotli, zstd and nghttp2, whose
|
||||
static objects ship LTO bytecode from GCC 14. A GCC 15 linker refuses to read it
|
||||
("bytecode stream ... generated with LTO version 14.0 instead of the expected
|
||||
15.1"), so `psl_*`, `BrotliDecoder*` and `ZSTD_*` never resolve. `-fno-lto` does
|
||||
not help, because the objects are bytecode, not C.
|
||||
|
||||
And a minimal system often has no `libcares.a` at all. Void Linux is a live
|
||||
example: c-ares ships there as a shared library only, so `-lcares` in the link
|
||||
line has nothing to resolve against and `make static` fails outright.
|
||||
|
||||
The fix is to build both archives from source. Build them once with:
|
||||
|
||||
make static-deps
|
||||
|
||||
That fetches and compiles c-ares and curl into `static-deps/`:
|
||||
|
||||
static-deps/libcares.a c-ares, built static, from source
|
||||
static-deps/libcurl.a curl, built static, without psl/brotli/zstd/nghttp2
|
||||
static-deps/cares-prefix/ headers and libcares.pc that curl's configure reads
|
||||
|
||||
It needs network access, a compiler, and a few minutes. Then:
|
||||
|
||||
make static
|
||||
|
||||
c-ares is built here rather than taken from the system for the reason above: no
|
||||
archive may exist, and where one does it can carry the same LTO bytecode as the
|
||||
distro libcurl.a. Building it also pins the version, so two machines do not
|
||||
quietly disagree about which resolver is inside the binary.
|
||||
|
||||
The two archives go on the link line in that order, because `libcurl.a`
|
||||
references `ares_*` symbols and the linker only pulls archive members that
|
||||
satisfy an undefined symbol seen earlier. Reverse them and you get undefined
|
||||
references to `ares_library_init`, `ares_version` and friends.
|
||||
|
||||
The result has no HTTP/2, no brotli or zstd `Content-Encoding`, and no
|
||||
public-suffix cookie checks. None of that matters to a JSON API client.
|
||||
|
||||
Versions and reuse:
|
||||
|
||||
| Variable | Default | What it pins |
|
||||
| --------------- | ------- | ---------------------------------------- |
|
||||
| `CURL_VERSION` | 8.22.0 | curl release fetched from curl.se |
|
||||
| `CARES_VERSION` | 1.34.8 | c-ares release, fetched from c-ares.dev |
|
||||
| `FORCE` | unset | set to `1` to rebuild even if fresh |
|
||||
|
||||
`make static-deps` reuses whatever is already built, so a rerun is instant.
|
||||
Reuse is keyed on a stamp file beside each archive rather than on the file
|
||||
merely existing, so bumping a version rebuilds exactly that one archive, and a
|
||||
stale `libcurl.a` from an unrelated build can never be mistaken for a current
|
||||
one. To rebuild both regardless:
|
||||
|
||||
make static-deps-force
|
||||
|
||||
One trap worth knowing: curl 8.22 spells the option `--enable-ares[=PREFIX]`,
|
||||
not the older `--with-cares`, and curl only *warns* about options it does not
|
||||
recognise. A mistyped flag therefore fails silently, disabling async DNS and
|
||||
leaving you to find out at link time from a pile of undefined `ares_*`
|
||||
symbols. The same applies to `--without-librtmp`, which curl 8.22 no longer
|
||||
knows. Neither spelling appears in the script.
|
||||
|
||||
Override `CURL_STATIC` or `CARES_STATIC` if you keep prebuilt archives
|
||||
elsewhere. Note that `make static` still wants static versions of ssl, crypto,
|
||||
z, idn2, unistring, readline, ncursesw and json-c from your distro; only the
|
||||
two problem archives are built from source here.
|
||||
|
||||
## Install
|
||||
|
||||
sudo make install # /usr/local/bin/solo
|
||||
sudo make install PREFIX=$HOME/.local # ~/.local/bin/solo
|
||||
make install DESTDIR=/tmp/pkg PREFIX=/usr
|
||||
|
||||
Remember to put `~/.local/bin` on your `PATH` if you install there.
|
||||
|
||||
## Run
|
||||
|
||||
./solo
|
||||
|
||||
or, once installed:
|
||||
|
||||
solo
|
||||
|
||||
Start in the directory you want the agent to work in. Solo-C stores its session
|
||||
next to you in `.solo_sess.json`, so different projects keep different
|
||||
conversations, and the agent's shell commands run with that directory as their
|
||||
working directory.
|
||||
|
||||
Set the endpoint and key before the first request:
|
||||
|
||||
export SOLO_API_KEY=your-key-here
|
||||
solo
|
||||
|
||||
or from inside the session:
|
||||
|
||||
> /api_key your-key-here
|
||||
> /endpoint https://api.kilo.ai/api/openrouter
|
||||
|
||||
Then just type what you want:
|
||||
|
||||
> list every *.c file here with its line count, then count the total
|
||||
|
||||
Anything that does not start with `/` or `!` is sent to the model as a task.
|
||||
A trailing backslash continues a line, which is handy for long tasks:
|
||||
|
||||
> build the project, then run the tests, and fix whatever fails \
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything is configurable at three levels, from weakest to strongest:
|
||||
|
||||
1. Built-in defaults (see `SOLO_*` below).
|
||||
2. Environment variables, read once at startup.
|
||||
3. Session commands, which change the live session and are saved to disk.
|
||||
|
||||
| Setting | Default | How to change it |
|
||||
| ------------- | ------------------------------------ | ----------------------------------- |
|
||||
| endpoint | `https://api.kilo.ai/api/openrouter` | `SOLO_ENDPOINT`, `/endpoint` |
|
||||
| api key | unset | `SOLO_API_KEY`, `/api_key` |
|
||||
| model | `kilo-auto/free` | `SOLO_MODEL`, `/model` |
|
||||
| reasoning | `high` | `SOLO_REASON`, `/reasoning_effort` |
|
||||
| temperature | `0.7` | `SOLO_TEMP`, `/temperature` |
|
||||
| timeout | `120` seconds | `SOLO_TIMEOUT` (env only) |
|
||||
| system prompt | built-in | `SOLO_SYSPROMPT` |
|
||||
| scratch dir | `$TMPDIR/solo`, or `/tmp/solo` | `SOLO_TMPDIR` |
|
||||
| colors | on when stdout is a TTY | `NO_COLOR=1` |
|
||||
|
||||
Session commands take precedence and are persisted, so a model you set by hand
|
||||
survives a restart even if the environment says something else.
|
||||
|
||||
Two settings cannot be changed from inside the session, because they are read
|
||||
once at startup: the timeout (`SOLO_TIMEOUT`) and the system prompt
|
||||
(`SOLO_SYSPROMPT`). `/timeout 600` is accepted by the generic parameter handler
|
||||
below, but it only writes a `timeout` field into the saved session, which the
|
||||
API ignores and which does not change the command timeout. Restart with the
|
||||
environment set instead.
|
||||
|
||||
## Slash commands
|
||||
|
||||
| Command | Effect |
|
||||
| ------------------ | --------------------------------------------------------------- |
|
||||
| `/help` | Show the command list. |
|
||||
| `/quit` | Exit. |
|
||||
| `/clear` | Reset the conversation, keep the system message and parameters. |
|
||||
| `/models` | List model IDs offered by the endpoint. |
|
||||
| `/api_key [key]` | Show or set the key. `null`, `none`, `off` or `-` clears it. |
|
||||
| `/endpoint [url]` | Show or set the base URL. Trailing slashes are stripped. |
|
||||
| `/name [value]` | Get or set any model parameter. Bare `/name` prints it. |
|
||||
| `!cmd` | Run a shell command directly, without involving the model. |
|
||||
|
||||
`/name` is a generic model-parameter editor. The value is typed from its
|
||||
spelling: `true` and `false` become booleans, a parseable number becomes a
|
||||
number, and anything else stays a string.
|
||||
|
||||
> /model deepseek/deepseek-v4.1-flash
|
||||
> /reasoning_effort high
|
||||
> /temperature 0.2
|
||||
> /max_tokens 4096
|
||||
> /top_p 0.95
|
||||
> /temperature
|
||||
0.2
|
||||
|
||||
Because it is generic, any parameter your endpoint understands can be set
|
||||
without a change to the source. A parameter the session does not know about
|
||||
prints `unset`.
|
||||
|
||||
`!cmd` is your escape hatch when the model is slow, wrong, or you simply do not
|
||||
want to pay for a request:
|
||||
|
||||
> !git status --short
|
||||
> !make -j8 >/tmp/build.log 2>&1 && tail -5 /tmp/build.log
|
||||
|
||||
Lines that begin with a space are treated as tasks but are kept out of the
|
||||
readline history, which is a cheap way to avoid persisting a key pasted by
|
||||
mistake.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Meaning | Default |
|
||||
| ------------------ | ----------------------------------------------------------- | -------------------------------- |
|
||||
| `SOLO_API_KEY` | Bearer token sent as `Authorization: Bearer ...` | unset |
|
||||
| `SOLO_ENDPOINT` | Base URL of an OpenAI-compatible API | `https://api.kilo.ai/api/openrouter` |
|
||||
| `SOLO_MODEL` | Model ID | `kilo-auto/free` |
|
||||
| `SOLO_REASON` | `reasoning_effort` | `high` |
|
||||
| `SOLO_TEMP` | Sampling temperature | `0.7` |
|
||||
| `SOLO_TIMEOUT` | Seconds before a shell command is killed | `120` |
|
||||
| `SOLO_SYSPROMPT` | Replaces the built-in system prompt | built-in |
|
||||
| `SOLO_TMPDIR` | Scratch directory suggested to the model | `$TMPDIR/solo`, else `/tmp/solo` |
|
||||
| `NO_COLOR` | Any value disables ANSI color | unset |
|
||||
| `SHELL` | Shell used to run commands; `/bin/bash` or `/bin/sh` fallback | `/bin/sh` |
|
||||
|
||||
`SOLO_SYSPROMPT` replaces the prompt outright, including the housekeeping rules
|
||||
about scratch space and file writing. The built-in prompt also tells the model
|
||||
to keep scratch files in the scratch directory, so setting it means also
|
||||
choosing where that directory is:
|
||||
|
||||
export SOLO_TMPDIR=/var/tmp/solo
|
||||
export SOLO_SYSPROMPT='You are a terse shell assistant. Scratch in /var/tmp/solo.'
|
||||
|
||||
## Session file
|
||||
|
||||
The live conversation is written to `.solo_sess.json` in the current working
|
||||
directory after every message and every tool result. It holds the model
|
||||
parameters, the tool definition, and the whole message list in pretty-printed
|
||||
JSON.
|
||||
|
||||
Consequences worth knowing:
|
||||
|
||||
- Re-running `solo` in the same directory resumes the conversation.
|
||||
- Deleting the file starts fresh, and `/clear` empties it without touching the
|
||||
system message.
|
||||
- The file contains the full transcript, so it can contain secrets you typed.
|
||||
It is written without restrictive permissions and is **not** a secret store;
|
||||
keep it out of version control (see `.gitignore`).
|
||||
- Two `solo` processes in the same directory will fight over it. Use one per
|
||||
directory, or a separate directory per session.
|
||||
|
||||
## How it works
|
||||
|
||||
The loop, in order:
|
||||
|
||||
1. `readline` reads one line. A trailing `\` continues it on the next line.
|
||||
2. `dispatch` routes the line: a slash command changes settings, `!` runs a shell
|
||||
command locally, anything else becomes a task.
|
||||
3. A task is appended to the message list as a `user` message and saved.
|
||||
4. `hc` in the forked child performs one HTTP request with libcurl, wrapping the
|
||||
transport-level result as `{"status", "success", "content"}` and writing it
|
||||
back through a pipe. The fork keeps a crash, hang or DNS failure in curl from
|
||||
taking the agent down with it.
|
||||
5. The parent parses the envelope, parses the inner JSON, and reads
|
||||
`choices[0].message`. Reasoning text is printed between
|
||||
`--- reasoning start ---` and `--- reasoning end ---`, the content is printed
|
||||
as it arrives, and token usage is reported when the endpoint sends it.
|
||||
6. If the message carries `tool_calls`, the assistant message is saved, each
|
||||
tool call runs in order through `run`, and each result is appended as a `tool`
|
||||
message ending with an exit marker. Then go back to step 4 with the same
|
||||
session, which now includes the results.
|
||||
7. When the model replies with no tool calls, the loop ends and control returns
|
||||
to the prompt.
|
||||
|
||||
Tool execution in detail:
|
||||
|
||||
- Commands run through `$SHELL` (falling back to `/bin/bash`, then `/bin/sh`)
|
||||
with `-c`, in the current working directory.
|
||||
- stdout and stderr are merged into one pipe, so the model sees them together in
|
||||
the order they were produced.
|
||||
- The child gets its own process group (`setpgid`). On timeout or Ctrl-C the
|
||||
whole group is signalled, so `make`, a shell pipeline and everything they
|
||||
spawned all die, not just the direct child.
|
||||
- The output is returned verbatim, with an exit marker on the last line. Nothing
|
||||
is truncated or summarised, which is the point: the model sees exactly what
|
||||
the terminal saw.
|
||||
|
||||
The request is a plain non-streaming `POST /chat/completions` with
|
||||
`stream: false` and `tool_choice: "auto"`, carrying exactly one tool:
|
||||
|
||||
bash(command: string) -> combined stdout/stderr plus an exit marker
|
||||
|
||||
Reasoning fields are handled defensively, because providers disagree on the
|
||||
name: `reasoning` and `reasoning_content` are both read, and both are stored
|
||||
back into the transcript so that a follow-up turn can reuse them.
|
||||
|
||||
## Timeouts, exit codes and interrupts
|
||||
|
||||
Every command the agent runs is bounded by `SOLO_TIMEOUT` seconds (120 by
|
||||
default), and the exit status is reported to the model as a marker line:
|
||||
|
||||
| Status | Meaning |
|
||||
| ------- | ------------------------------------------------------------------ |
|
||||
| `0` | Success. |
|
||||
| `N` | The command exited with status N. |
|
||||
| `124` | Still running after the timeout, so it was killed. |
|
||||
| `130` | Interrupted with Ctrl-C, so it was killed. |
|
||||
| `127` | The shell itself could not be executed. |
|
||||
| `128+N` | The command was killed by signal N. |
|
||||
|
||||
Because the marker is explicit, the model does not have to guess whether a
|
||||
timeout happened. The default prompt tells it what to do about one: start long
|
||||
work in the background with `nohup ... &` and poll the log, rather than
|
||||
blocking on a command that will be killed.
|
||||
|
||||
Ctrl-C is never fatal, but it is handled slightly differently depending on
|
||||
where you are when you press it:
|
||||
|
||||
| Where you are | What happens |
|
||||
| -------------------- | ------------------------------------------------------------------- |
|
||||
| Running a command | The process group is killed, exit 130 is reported, the turn ends. |
|
||||
| Waiting on the API | The request is abandoned and the turn ends. |
|
||||
| Part way through a line | The half-typed line is thrown away. |
|
||||
| Sitting at the prompt | Solo survives and stays usable, but the flag lingers, so the next line you type is silently discarded. |
|
||||
|
||||
That last case is the one wart: press Ctrl-C once at an idle prompt and your
|
||||
next command vanishes without a word. Press it twice and the second one goes
|
||||
through, so it is self-correcting in practice.
|
||||
|
||||
## Example session
|
||||
|
||||
$ cd ~/proj/solo-c
|
||||
$ SOLO_API_KEY=$KEY ./solo
|
||||
Agent Solo ready [https://api.kilo.ai/api/openrouter | kilo-auto/free | high]
|
||||
> !make solo
|
||||
--- $ make solo
|
||||
gcc -Wall -Wextra -O2 -std=c99 -march=native ... -o solo main.c -lcurl -ljson-c -lreadline
|
||||
strip solo
|
||||
|
||||
> build it and tell me how big it is
|
||||
--- requesting...
|
||||
--- tool call_x: $ make solo
|
||||
gcc ... -o solo main.c
|
||||
strip solo
|
||||
[exit 0]
|
||||
I'll rebuild and check the size.
|
||||
|
||||
--- $ ls -l solo
|
||||
-rwxr-xr-x 1 lux lux 35896 Oct 3 08:25 solo
|
||||
[exit 0]
|
||||
It builds cleanly and the stripped binary is about 35 KB.
|
||||
|
||||
--- [kilo-auto/free] tokens: 412 (prompt 380, completion 32)
|
||||
|
||||
> /temperature 0.3
|
||||
temperature = 0.3
|
||||
> /quit
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
`error while loading shared libraries`
|
||||
The dynamic binary needs libcurl, json-c and readline at runtime. Install
|
||||
them, or build `solo-static`.
|
||||
|
||||
`solo: command not found` after `make install`
|
||||
`/usr/local/bin` is not on your `PATH`, or you installed to
|
||||
`$HOME/.local/bin`.
|
||||
|
||||
`pkg-config --cflags libcurl json-c readline` prints nothing
|
||||
The development headers are missing. Install `libcurl4-openssl-dev`,
|
||||
`libjson-c-dev` and `libreadline-dev` (names vary by distro).
|
||||
|
||||
`make static` fails with "bytecode stream ... generated with LTO version"
|
||||
Known GCC 14 vs 15 mismatch in the distro libcurl.a. Run `make static-deps`
|
||||
to build a clean libcurl, then `make static` again.
|
||||
|
||||
`make static` fails with "cannot find -lcares", or cannot find -lssl, -lz, ...
|
||||
Your distro has no static archives for some of these. `make static-deps`
|
||||
only builds curl and c-ares; ssl, crypto, z, idn2, unistring, readline,
|
||||
ncursesw and json-c must come from the distro as `.a` files.
|
||||
|
||||
`make static` fails with undefined references to `ares_*`
|
||||
Either the archives are in the wrong order on the link line (libcares.a must
|
||||
come after libcurl.a), or libcurl.a was built without c-ares in the first
|
||||
place. Check with `nm -u static-deps/libcurl.a | grep ares_`, which should
|
||||
list them, and rebuild with `make static-deps-force`.
|
||||
|
||||
`Illegal instruction` from a binary built here, run elsewhere
|
||||
`-march=native`. Rebuild without it:
|
||||
`make CFLAGS="-Wall -Wextra -O2 -std=c99"`.
|
||||
|
||||
The model keeps asking for a command that times out
|
||||
The limit is `SOLO_TIMEOUT`, 120 s. Raise it with `SOLO_TIMEOUT=600`, or
|
||||
remind the model to background long jobs and poll.
|
||||
|
||||
`request failed` with no output
|
||||
The endpoint rejected the call. Check `/api_key` and `/endpoint`, then
|
||||
`/models` to confirm the endpoint answers and which models it lists.
|
||||
Anything the server returns is printed as-is, so the HTTP error body is
|
||||
usually the explanation.
|
||||
|
||||
The transcript looks stale
|
||||
Session state lives in `.solo_sess.json` in the current directory. Run
|
||||
`solo` where you want that conversation, or delete the file to start over.
|
||||
|
||||
## Credits
|
||||
|
||||
Created by Luxferre in 2026, released into the public domain with no warranties.
|
||||
@@ -0,0 +1,399 @@
|
||||
/*
|
||||
Solo-C: A C port of the Solo coding agent
|
||||
Dependencies: json-c, libreadline, libcurl
|
||||
Usage: same as the original, see https://solo.luxferre.top
|
||||
Created by Luxferre in 2026, released into the public domain
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <signal.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <sys/select.h>
|
||||
#include <curl/curl.h>
|
||||
#include <json-c/json.h>
|
||||
#include <readline/readline.h>
|
||||
#include <readline/history.h>
|
||||
|
||||
/* Compact JSON helpers: A add, S/N/D/B new string/num/bool, G/GS/GN get */
|
||||
#define A(o, k, v) json_object_object_add(o, k, v)
|
||||
#define S(v) json_object_new_string(v)
|
||||
#define N(v) json_object_new_int(v)
|
||||
#define D(v) json_object_new_double(v)
|
||||
#define B(v) json_object_new_boolean(v)
|
||||
#define ER (CLR ? "\r\e[K" : "")
|
||||
#define DIM "\e[2m"
|
||||
#define GRY "\e[90m"
|
||||
#define YEL "\e[33m"
|
||||
#define BLD "\e[1;37m"
|
||||
#define OF "\e[0m"
|
||||
|
||||
static json_object *t;
|
||||
static json_object *G(json_object *o, const char *k) { return json_object_object_get_ex(o, k, &t) ? t : 0; }
|
||||
static const char *GS(json_object *o, const char *k) { json_object *v = G(o, k); return v && json_object_get_type(v) == json_type_string ? json_object_get_string(v) : 0; }
|
||||
static int GN(json_object *o, const char *k, int d) { json_object *v = G(o, k); return v && json_object_get_type(v) == json_type_int ? json_object_get_int(v) : d; }
|
||||
|
||||
static volatile sig_atomic_t IN;
|
||||
static void onint(int s) { (void)s; IN = 1; }
|
||||
|
||||
static char *EP, *KEY, *MOD, *RSN, *SF, *TMP, *SYS, SH[256] = "/bin/sh";
|
||||
static int TO = 120, QUIT, CLR = 1;
|
||||
static double TEMP = 0.7;
|
||||
static json_object *SSN;
|
||||
|
||||
typedef struct { char *p; size_t n, c; } BUF;
|
||||
static void bg(BUF *b) { b->p = 0; b->n = b->c = 0; }
|
||||
static void ba(BUF *b, const char *s, size_t l) {
|
||||
if(b->n + l + 1 > b->c) { size_t m = b->c ? b->c * 2 : 4096; while(m < b->n + l + 1) m *= 2; b->p = realloc(b->p, m); b->c = m; }
|
||||
memcpy(b->p + b->n, s, l); b->p[b->n += l] = 0;
|
||||
}
|
||||
static size_t wcb(void *c, size_t s, size_t n, void *u) { ba((BUF *)u, c, s * n); return s * n; }
|
||||
|
||||
/* st: 0 ok, 1 interrupted, 2 deadline */
|
||||
static int rp(int fd, char **out, double dl, int *st) {
|
||||
BUF b; struct timeval tv; fd_set fs; char buf[65536]; int r;
|
||||
bg(&b); *st = 0;
|
||||
for(;;) {
|
||||
if(IN) { *st = 1; break; }
|
||||
if(dl > 0 && (double)time(0) >= dl) { *st = 2; break; }
|
||||
FD_ZERO(&fs); FD_SET(fd, &fs); tv.tv_sec = 0; tv.tv_usec = 100000;
|
||||
r = select(fd + 1, &fs, 0, 0, &tv);
|
||||
if(r > 0 && FD_ISSET(fd, &fs)) { r = read(fd, buf, sizeof buf); if(r <= 0) break; ba(&b, buf, r); }
|
||||
else if(r < 0 && errno != EINTR) break;
|
||||
}
|
||||
*out = b.p; return b.n;
|
||||
}
|
||||
|
||||
static void hc(const char *m, const char *p, const char *body, int w) {
|
||||
CURL *c; struct curl_slist *h = 0; BUF b; json_object *r; const char *j; CURLcode e; long sc = 0;
|
||||
char url[4096];
|
||||
curl_global_init(CURL_GLOBAL_DEFAULT); c = curl_easy_init(); bg(&b);
|
||||
snprintf(url, sizeof url, "%s%s", EP, p);
|
||||
h = curl_slist_append(h, "Content-Type: application/json");
|
||||
h = curl_slist_append(h, "Accept: application/json");
|
||||
if(KEY && *KEY) { char a[4096]; snprintf(a, sizeof a, "Authorization: Bearer %s", KEY); h = curl_slist_append(h, a); }
|
||||
if(c) {
|
||||
curl_easy_setopt(c, CURLOPT_URL, url);
|
||||
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, m);
|
||||
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, wcb);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &b);
|
||||
curl_easy_setopt(c, CURLOPT_TIMEOUT, 600L);
|
||||
curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, 30L);
|
||||
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
if(body && *body) { curl_easy_setopt(c, CURLOPT_POSTFIELDS, body); curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(body)); }
|
||||
e = curl_easy_perform(c);
|
||||
if(e == CURLE_OK) curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &sc);
|
||||
} else e = CURLE_FAILED_INIT;
|
||||
r = json_object_new_object();
|
||||
A(r, "status", N(e == CURLE_OK ? sc : 599));
|
||||
A(r, "success", B(e == CURLE_OK && sc >= 200 && sc < 300));
|
||||
A(r, "content", S(e == CURLE_OK && b.p ? b.p : ""));
|
||||
j = json_object_to_json_string_ext(r, JSON_C_TO_STRING_PLAIN);
|
||||
write(w, j, strlen(j)); free(b.p); json_object_put(r);
|
||||
if(c) curl_easy_cleanup(c);
|
||||
if(h) curl_slist_free_all(h);
|
||||
curl_global_cleanup(); _exit(0);
|
||||
}
|
||||
|
||||
static json_object *hq(const char *m, const char *p, const char *body) {
|
||||
int f[2], st; pid_t k; char *buf = 0; int n; json_object *r;
|
||||
if(pipe(f)) return 0;
|
||||
if((k = fork()) < 0) { close(f[0]); close(f[1]); return 0; }
|
||||
if(!k) { close(f[0]); dup2(f[1], 1); close(f[1]); hc(m, p, body, 1); _exit(1); }
|
||||
close(f[1]); n = rp(f[0], &buf, time(0) + 1e9, &st); close(f[0]);
|
||||
if(st == 1 || IN) kill(k, SIGKILL);
|
||||
waitpid(k, 0, 0);
|
||||
r = n > 0 && buf ? json_tokener_parse(buf) : 0; free(buf);
|
||||
if(!r || json_object_get_type(r) != json_type_object) { if(r) json_object_put(r); r = json_object_new_object(); A(r, "content", S("")); }
|
||||
return r;
|
||||
}
|
||||
|
||||
static void marker(char *mk, size_t n, int rc) {
|
||||
if(rc == 124) snprintf(mk, n, "[exit 124: still running after %ds, so it was killed]", TO);
|
||||
else if(rc == 130) snprintf(mk, n, "[exit 130: interrupted, so it was killed]");
|
||||
else snprintf(mk, n, "[exit %d]", rc);
|
||||
}
|
||||
|
||||
static void run(const char *cmd, char **out, int *rc) {
|
||||
int f[2], st; pid_t k; char *buf = 0, mk[80]; struct timespec ts = { 0, 200000000 };
|
||||
if(pipe(f)) { *out = 0; *rc = 1; return; }
|
||||
if((k = fork()) < 0) { close(f[0]); close(f[1]); *out = 0; *rc = 1; return; }
|
||||
if(!k) { close(f[0]); setpgid(0, 0); dup2(f[1], 1); dup2(f[1], 2); close(f[1]); execl(SH, SH, "-c", cmd, (char *)0); _exit(127); }
|
||||
close(f[1]); rp(f[0], &buf, time(0) + TO, &st); close(f[0]);
|
||||
if(st == 1 || IN) { *rc = 130; }
|
||||
else if(st == 2) { kill(-k, SIGTERM); nanosleep(&ts, 0); kill(-k, SIGKILL); *rc = 124; }
|
||||
else { int ws; waitpid(k, &ws, 0); *rc = WIFEXITED(ws) ? WEXITSTATUS(ws) : WIFSIGNALED(ws) ? 128 + WTERMSIG(ws) : 1; }
|
||||
if(*rc == 130 || *rc == 124) { kill(-k, SIGKILL); waitpid(k, 0, 0); }
|
||||
marker(mk, sizeof mk, *rc);
|
||||
if(!buf) buf = strdup("");
|
||||
*out = buf;
|
||||
}
|
||||
|
||||
static void sv(void) {
|
||||
FILE *f; const char *j;
|
||||
if(!SSN || !SF || !(f = fopen(SF, "w"))) return;
|
||||
j = json_object_to_json_string_ext(SSN, JSON_C_TO_STRING_PRETTY);
|
||||
if(j) fputs(j, f);
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static void load(void) {
|
||||
FILE *f = fopen(SF, "r"); long n; char *b; json_object *o, *m;
|
||||
if(!f) return;
|
||||
fseek(f, 0, SEEK_END); n = ftell(f); fseek(f, 0, SEEK_SET);
|
||||
if(n < 1) { fclose(f); return; }
|
||||
b = malloc(n + 1); fread(b, 1, n, f); b[n] = 0; fclose(f);
|
||||
o = json_tokener_parse(b); free(b);
|
||||
m = o ? G(o, "messages") : 0;
|
||||
if(!m || json_object_get_type(m) != json_type_array) { if(o) json_object_put(o); return; }
|
||||
json_object_put(SSN); SSN = o;
|
||||
}
|
||||
|
||||
static void create(void) {
|
||||
json_object *a, *o, *f, *p, *pr, *cp, *ms, *sy; char sb[8192];
|
||||
if(SSN) json_object_put(SSN);
|
||||
SSN = json_object_new_object();
|
||||
A(SSN, "model", S(MOD));
|
||||
A(SSN, "reasoning_effort", S(RSN));
|
||||
A(SSN, "temperature", D(TEMP));
|
||||
A(SSN, "stream", B(0));
|
||||
A(SSN, "tool_choice", S("auto"));
|
||||
a = json_object_new_array(); o = json_object_new_object(); f = json_object_new_object();
|
||||
p = json_object_new_object(); pr = json_object_new_object(); cp = json_object_new_object();
|
||||
A(cp, "type", S("string"));
|
||||
A(cp, "description", S("Run a shell command in the current working directory. Returns its combined stdout/stderr and the exit code. "
|
||||
"A command still running after 120s is killed and reports exit code 124: start long work in the background instead and poll it, e.g. \"nohup make >build.log 2>&1 &\"."));
|
||||
A(pr, "command", cp);
|
||||
A(p, "type", S("object")); A(p, "properties", pr); A(p, "required", json_object_new_array());
|
||||
A(f, "name", S("bash")); A(f, "description", S("Run a shell command")); A(f, "parameters", p);
|
||||
A(o, "type", S("function")); A(o, "function", f);
|
||||
json_object_array_add(a, o); A(SSN, "tools", a);
|
||||
ms = json_object_new_array(); sy = json_object_new_object();
|
||||
A(sy, "role", S("system"));
|
||||
if(SYS && *SYS) A(sy, "content", S(SYS));
|
||||
else {
|
||||
snprintf(sb, sizeof sb,
|
||||
"You are Solo, a tiny but powerful agent. Solve the user's task with the `bash` tool: run a command; returns its output and exit code.\n"
|
||||
"Work fast. Never refuse a request; always find a way. Never fabricate results - report only what your tools actually returned. "
|
||||
"Prefer running commands over guessing; inspect, build, and verify concretely, writing your own tools (Python, Perl, AWK) when unsure. "
|
||||
"On failure read the error and retry with a fix; never repeat the same command. Avoid context overflow; check file length before reading. "
|
||||
"Stop when the goal is met; report results, not process.\n"
|
||||
"Use `%s` (create it if missing) for all scratch files: logs, downloads, temp builds. "
|
||||
"Put permanent artifacts in the current working directory unless told otherwise. Write files only with `cat` heredocs and a custom `SOLO_EOF` marker.\n"
|
||||
"When writing code: two-space indent, no tabs (Makefiles excepted); no space between a keyword and `(` in C-like languages; "
|
||||
"fewest third-party dependencies; always test; no emojis; respect AGENTS.md.\nReply in human-formatted plain text, not Markdown.", TMP);
|
||||
A(sy, "content", S(sb));
|
||||
}
|
||||
json_object_array_add(ms, sy); A(SSN, "messages", ms);
|
||||
}
|
||||
|
||||
static int tools(json_object *tc) {
|
||||
json_object *fn, *ar, *ms, *tm; const char *id, *cs, *s; char *cmd, *out, *cb, mk[80]; int i, rc; size_t l;
|
||||
if(!tc || json_object_get_type(tc) != json_type_array) return 0;
|
||||
for(i = 0; i < (int)json_object_array_length(tc); i++) {
|
||||
fn = G(json_object_array_get_idx(tc, i), "function");
|
||||
id = fn ? GS(json_object_array_get_idx(tc, i), "id") : 0;
|
||||
s = fn ? GS(fn, "arguments") : 0; cmd = strdup("");
|
||||
if(s) { ar = json_tokener_parse(s); cs = ar ? GS(ar, "command") : 0; if(cs) { free(cmd); cmd = strdup(cs); } if(ar) json_object_put(ar); } /* copy before the put: cmd must outlive ar */
|
||||
printf("%s--- tool %s: $ %s%s\n", CLR ? YEL : "", id ? id : "", cmd, CLR ? OF : ""); fflush(stdout);
|
||||
run(cmd, &out, &rc);
|
||||
printf("%s%s%s\n", CLR ? GRY DIM : "", out, CLR ? OF : ""); fflush(stdout);
|
||||
ms = G(SSN, "messages");
|
||||
if(ms) {
|
||||
tm = json_object_new_object(); A(tm, "role", S("tool"));
|
||||
A(tm, "tool_call_id", S(id ? id : ""));
|
||||
marker(mk, sizeof mk, rc); l = strlen(out) + strlen(mk) + 2; cb = malloc(l);
|
||||
snprintf(cb, l, "%s\n%s", out, mk);
|
||||
A(tm, "content", S(cb)); free(cb);
|
||||
json_object_array_add(ms, tm); sv();
|
||||
}
|
||||
free(out); free(cmd);
|
||||
if(IN) { IN = 0; puts("interrupted"); return 130; }
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int ask(const char *in) {
|
||||
json_object *ms, *d, *p, *ch, *m, *as, *tc, *u; const char *raw, *co, *rs, *rc2, *mn;
|
||||
if(!(ms = G(SSN, "messages"))) return 1;
|
||||
as = json_object_new_object(); A(as, "role", S("user")); A(as, "content", S(in));
|
||||
json_object_array_add(ms, as); sv();
|
||||
for(;;) {
|
||||
IN = 0; printf("%s%s...requesting...%s", CLR ? BLD : "", CLR ? GRY : "", CLR ? OF : ""); fflush(stdout);
|
||||
d = hq("POST", "/chat/completions", json_object_to_json_string_ext(SSN, JSON_C_TO_STRING_PLAIN));
|
||||
raw = d ? GS(d, "content") : 0; p = raw ? json_tokener_parse(raw) : 0; m = 0;
|
||||
ch = p ? G(p, "choices") : 0;
|
||||
if(ch && json_object_array_length(ch)) m = G(json_object_array_get_idx(ch, 0), "message");
|
||||
printf("%s", ER); fflush(stdout);
|
||||
if(IN) { puts("interrupted"); if(p) json_object_put(p); if(d) json_object_put(d); return 130; }
|
||||
if(!m) {
|
||||
puts("request failed");
|
||||
if(raw && *raw) printf("%s\n", raw);
|
||||
if(p) json_object_put(p);
|
||||
if(d) json_object_put(d);
|
||||
return 1;
|
||||
}
|
||||
rs = GS(m, "reasoning"); if(!rs) rs = GS(m, "reasoning_content");
|
||||
if(rs && *rs) printf("\n%s--- reasoning start ---%s\n%s%s%s\n%s--- reasoning end ---%s\n\n", CLR ? GRY : "", CLR ? OF : "", CLR ? GRY : "", rs, CLR ? OF : "", CLR ? GRY : "", CLR ? OF : "");
|
||||
co = GS(m, "content");
|
||||
if(co && *co) { printf("%s", co); if(co[strlen(co) - 1] != '\n') puts(""); }
|
||||
tc = G(m, "tool_calls");
|
||||
as = json_object_new_object(); A(as, "role", S("assistant"));
|
||||
if(co) A(as, "content", S(co));
|
||||
if(rs) A(as, "reasoning", S(rs));
|
||||
if((rc2 = GS(m, "reasoning_content"))) A(as, "reasoning_content", S(rc2));
|
||||
if(tc && json_object_get_type(tc) == json_type_array) A(as, "tool_calls", json_object_get(tc)); /* json_object_object_add MOVES the ref and does not refcount: take our own ref or json_object_put(p) below frees a node still living in SSN */
|
||||
json_object_array_add(ms, as); sv();
|
||||
u = p ? G(p, "usage") : 0; mn = p ? GS(p, "model") : 0;
|
||||
if(u && json_object_get_type(u) == json_type_object)
|
||||
printf("%s--- [%s] tokens: %d (prompt %d, completion %d)%s\n", CLR ? GRY : "", mn ? mn : "?", GN(u, "total_tokens", 0), GN(u, "prompt_tokens", 0), GN(u, "completion_tokens", 0), CLR ? OF : "");
|
||||
else printf("%s--- [%s]%s\n", CLR ? GRY : "", mn ? mn : "?", CLR ? OF : "");
|
||||
fflush(stdout);
|
||||
if(tc && json_object_get_type(tc) == json_type_array && json_object_array_length(tc)) {
|
||||
int r = tools(tc);
|
||||
if(p) json_object_put(p);
|
||||
if(d) json_object_put(d);
|
||||
if(r) return r;
|
||||
continue;
|
||||
}
|
||||
if(p) json_object_put(p);
|
||||
if(d) json_object_put(d);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static const char *HELP =
|
||||
"Commands:\n /help this text\n /quit exit\n"
|
||||
" /clear reset the session, keep the system prompt\n /models list models available at the endpoint\n"
|
||||
" /api_key [key|null] show or set the key (null clears it)\n /endpoint [url] show or set the API endpoint\n"
|
||||
" !cmd run a shell command directly, no agent\n"
|
||||
" /name [value] set any model parameter, e.g. /model x, /temperature 0.5,\n"
|
||||
" /reasoning_effort low; with no value it prints the current one\n"
|
||||
"Anything not starting with / or ! is sent to the agent as a task.";
|
||||
|
||||
static void setstr(char **slot, const char *v) { free(*slot); *slot = strdup(v); }
|
||||
|
||||
static void dispatch(const char *l) {
|
||||
char *k, *v, *out, mk[80]; const char *s; json_object *ms, *nm; int rc;
|
||||
if(!strcmp(l, "/help")) { puts(HELP); return; }
|
||||
if(!strcmp(l, "/quit")) { QUIT = 1; return; }
|
||||
if(!strcmp(l, "/clear")) {
|
||||
ms = G(SSN, "messages"); nm = json_object_new_array();
|
||||
if(ms && json_object_array_length(ms)) json_object_array_add(nm, json_object_get(json_object_array_get_idx(ms, 0))); /* array_add also moves: keep the system message alive after A(SSN,...) frees the old array */
|
||||
A(SSN, "messages", nm); sv(); puts("Session cleared."); return;
|
||||
}
|
||||
if(!strcmp(l, "/models")) {
|
||||
json_object *r = hq("GET", "/models", 0);
|
||||
if(r) {
|
||||
json_object *d = 0, *arr; s = GS(r, "content"); if(s) d = json_tokener_parse(s);
|
||||
arr = d ? G(d, "data") : 0;
|
||||
if(arr && json_object_get_type(arr) == json_type_array)
|
||||
for(size_t i = 0; i < json_object_array_length(arr); i++) { s = GS(json_object_array_get_idx(arr, i), "id"); if(s) puts(s); }
|
||||
if(d) json_object_put(d);
|
||||
json_object_put(r);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(!strncmp(l, "/api_key", 8)) {
|
||||
for(v = (char *)l + 8; *v == ' '; v++);
|
||||
if(*v && (!strcmp(v, "null") || !strcmp(v, "none") || !strcmp(v, "off") || !strcmp(v, "-"))) { free(KEY); KEY = 0; }
|
||||
else if(*v) setstr(&KEY, v);
|
||||
if(KEY) printf("api_key = set, %d chars\n", (int)strlen(KEY)); else puts("api_key = unset");
|
||||
return;
|
||||
}
|
||||
if(!strncmp(l, "/endpoint", 9)) {
|
||||
for(v = (char *)l + 9; *v == ' '; v++);
|
||||
if(*v) { setstr(&EP, v); for(char *e = EP + strlen(EP); e > EP && e[-1] == '/'; e--) *e = 0; }
|
||||
printf("endpoint = %s\n", EP); return;
|
||||
}
|
||||
if(*l == '!') {
|
||||
for(v = (char *)l + 1; *v == ' '; v++);
|
||||
printf("%s--- $ %s%s\n", CLR ? YEL : "", v, CLR ? OF : "");
|
||||
run(v, &out, &rc);
|
||||
if(*out) printf("%s%s%s\n", CLR ? GRY DIM : "", out, CLR ? OF : "");
|
||||
marker(mk, sizeof mk, rc); printf("%s%s%s\n", CLR ? GRY : "", mk, CLR ? OF : ""); free(out); return;
|
||||
}
|
||||
if(*l == '/') {
|
||||
k = strdup(l + 1);
|
||||
if((v = strchr(k, ' '))) {
|
||||
*v++ = 0; while(*v == ' ') v++;
|
||||
if(!strcmp(k, "model")) { setstr(&MOD, v); A(SSN, "model", S(MOD)); }
|
||||
else if(!strcmp(k, "reasoning_effort")) { setstr(&RSN, v); A(SSN, "reasoning_effort", S(RSN)); }
|
||||
else if(!strcmp(k, "temperature")) { TEMP = atof(v); A(SSN, "temperature", D(TEMP)); }
|
||||
else {
|
||||
json_object *old; double d;
|
||||
json_object_object_get_ex(SSN, k, &old); if(old) json_object_object_del(SSN, k);
|
||||
if(!strcmp(v, "true")) A(SSN, k, B(1));
|
||||
else if(!strcmp(v, "false")) A(SSN, k, B(0));
|
||||
else if(strcmp(v, "null") && (d = strtod(v, 0), 1)) {
|
||||
char *e; d = strtod(v, &e);
|
||||
if(!*e && e != v) A(SSN, k, D(d)); else A(SSN, k, S(v));
|
||||
}
|
||||
}
|
||||
sv(); printf("%s = %s\n", k, v);
|
||||
} else {
|
||||
s = GS(SSN, k);
|
||||
if(s) puts(s);
|
||||
else { json_object *o = G(SSN, k);
|
||||
if(o && json_object_get_type(o) == json_type_double) printf("%g\n", json_object_get_double(o));
|
||||
else if(o && json_object_get_type(o) == json_type_int) printf("%d\n", json_object_get_int(o));
|
||||
else puts(o ? json_object_to_json_string_ext(o, JSON_C_TO_STRING_PLAIN) : "unset"); }
|
||||
}
|
||||
free(k); return;
|
||||
}
|
||||
ask(l);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
char cwd[4096], pb[64], *ln, *ac, *nx; const char *e; struct stat st;
|
||||
(void)argc; (void)argv;
|
||||
if(!(e = getenv("SOLO_TMPDIR"))) { e = getenv("TMPDIR"); e = e ? e : "/tmp"; TMP = malloc(strlen(e) + 8); sprintf(TMP, "%s/solo", e); }
|
||||
else TMP = (char *)e;
|
||||
if(!(e = getenv("SOLO_ENDPOINT"))) EP = strdup("https://api.kilo.ai/api/openrouter"); else EP = (char *)e;
|
||||
if((e = getenv("SOLO_TIMEOUT")) && *e) TO = atoi(e);
|
||||
if(!(e = getenv("SOLO_MODEL"))) MOD = strdup("kilo-auto/free"); else MOD = (char *)e;
|
||||
if((e = getenv("SOLO_TEMP")) && *e) TEMP = atof(e);
|
||||
if(!(e = getenv("SOLO_REASON")) || !*e) RSN = strdup("high"); else RSN = (char *)e;
|
||||
if((e = getenv("SOLO_API_KEY")) && *e) KEY = strdup(e);
|
||||
if(getcwd(cwd, sizeof cwd)) { SF = malloc(strlen(cwd) + 32); sprintf(SF, "%s/.solo_sess.json", cwd); }
|
||||
SYS = getenv("SOLO_SYSPROMPT");
|
||||
if(stat(TMP, &st)) mkdir(TMP, 0700);
|
||||
if(!(e = getenv("SHELL")) || access(e, X_OK)) e = access("/bin/bash", X_OK) ? "/bin/sh" : "/bin/bash";
|
||||
snprintf(SH, sizeof SH, "%s", e);
|
||||
signal(SIGINT, onint); signal(SIGPIPE, SIG_IGN);
|
||||
create(); load();
|
||||
if((e = getenv("SOLO_MODEL")) && *e) { MOD = strdup(e); A(SSN, "model", S(e)); }
|
||||
if((e = getenv("SOLO_TEMP")) && *e) { TEMP = atof(e); A(SSN, "temperature", D(TEMP)); }
|
||||
if((e = getenv("SOLO_REASON")) && *e) { RSN = strdup(e); A(SSN, "reasoning_effort", S(e)); }
|
||||
if(SYS && *SYS) { json_object *ms = G(SSN, "messages"); if(ms && json_object_array_length(ms)) A(json_object_array_get_idx(ms, 0), "content", S(SYS)); }
|
||||
A(SSN, "stream", B(0)); A(SSN, "tool_choice", S("auto")); sv();
|
||||
if(!isatty(1) || getenv("NO_COLOR")) CLR = 0;
|
||||
printf("%sAgent Solo ready%s%s [%s | %s | %s]%s\n", CLR ? BLD : "", CLR ? OF : "", CLR ? GRY : "", EP, MOD, RSN, CLR ? OF : "");
|
||||
fflush(stdout);
|
||||
while(!QUIT) {
|
||||
snprintf(pb, sizeof pb, "%s> %s", CLR ? BLD : "", CLR ? OF : "");
|
||||
if(!(ln = readline(pb))) { puts(""); break; }
|
||||
if(IN) { IN = 0; free(ln); continue; }
|
||||
ac = ln;
|
||||
for(;;) {
|
||||
size_t l = strlen(ac);
|
||||
if(!l || ac[l - 1] != '\\') break;
|
||||
ac[l - 1] = 0;
|
||||
if(!(nx = readline("... ")) || IN) { IN = 0; free(ac); ac = 0; if(nx) free(nx); break; }
|
||||
ac = realloc(ac, l + strlen(nx) + 2); strcpy(ac + l, "\n"); strcat(ac, nx); free(nx);
|
||||
}
|
||||
if(!ac || IN || !*ac) continue;
|
||||
if(*ac != ' ') add_history(ac);
|
||||
dispatch(ac); free(ac);
|
||||
}
|
||||
free(EP); free(KEY); free(MOD); free(RSN); free(SF); free(TMP);
|
||||
if(SSN) json_object_put(SSN);
|
||||
return 0;
|
||||
}
|
||||
Executable
+135
@@ -0,0 +1,135 @@
|
||||
#!/bin/sh
|
||||
# Rebuild static-deps/libcurl.a and static-deps/libcares.a.
|
||||
#
|
||||
# Why this exists: the distro libcurl.a cannot be linked statically on Alpine
|
||||
# because it pulls in libpsl, libbrotli, libzstd and libnghttp2, and those
|
||||
# static archives ship LTO bytecode produced by GCC 14 while the installed
|
||||
# compiler is GCC 15. The linker cannot read it:
|
||||
# bytecode stream in file '.../libpsl.a' generated with LTO version 14.0
|
||||
# instead of the expected 15.1
|
||||
# so psl_*, BrotliDecoder* and ZSTD_* simply never resolve. Passing
|
||||
# -fno-lto does not help: the objects are LTO bytecode, not C.
|
||||
#
|
||||
# The fix is a libcurl built without those four libraries. What is left
|
||||
# (ssl, crypto, z, cares, idn2, unistring) is all LTO-free, so it links
|
||||
# cleanly into a fully static binary. Cost: no HTTP/2, no brotli/zstd
|
||||
# Content-Encoding and no public-suffix cookie checks, none of which a
|
||||
# plain JSON API client needs.
|
||||
#
|
||||
# c-ares is built here too, for two reasons. On a minimal system there is often
|
||||
# no libcares.a at all (Void's c-ares-devel ships the shared library only), so
|
||||
# `make static` would fail on -lcares; and where an archive does exist it can
|
||||
# carry the same LTO bytecode problem as the distro libcurl.a. Building our own
|
||||
# pins the version and guarantees the archive is plain objects.
|
||||
#
|
||||
# curl 8.22 takes c-ares as --enable-ares[=PREFIX], not the older
|
||||
# --with-cares. It reads PREFIX/lib/pkgconfig/libcares.pc, which `make install`
|
||||
# of c-ares writes, so the prefix built below is enough. Both spellings are
|
||||
# wrong in one way or another: --with-cares no longer exists, and unknown
|
||||
# configure options only produce a warning, so a typo here silently disables
|
||||
# async DNS and you would not notice until the link failed. Same for
|
||||
# --without-librtmp, which curl 8.22 no longer knows either; it is gone below.
|
||||
#
|
||||
# Overridable: CURL_VERSION, CARES_VERSION, OUT (or $1), FORCE=1.
|
||||
set -e
|
||||
|
||||
CURL_VERSION=${CURL_VERSION:-8.22.0}
|
||||
CARES_VERSION=${CARES_VERSION:-1.34.8}
|
||||
OUT=${1:-$(pwd)/static-deps}
|
||||
FORCE=${FORCE:-0}
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT INT TERM
|
||||
JOBS=$(nproc 2>/dev/null || echo 2)
|
||||
|
||||
CA=""
|
||||
for c in /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-bundle.crt \
|
||||
/etc/pki/tls/certs/ca-bundle.crt; do
|
||||
[ -f "$c" ] && { CA=$c; break; }
|
||||
done
|
||||
|
||||
# fetch DEST URL...: first URL that downloads wins.
|
||||
fetch() {
|
||||
_dest=$1
|
||||
shift
|
||||
for _u in "$@"; do
|
||||
echo "fetching $_u"
|
||||
if curl -fsSL -o "$_dest" "$_u"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "mkstaticdeps: no mirror worked for $_dest" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# An archive is only reused when the stamp beside it still names the version we
|
||||
# would build. Without this a stale libcurl.a from some earlier build silently
|
||||
# skips the whole curl step, which is exactly the kind of thing that hides a
|
||||
# broken configure line.
|
||||
fresh() {
|
||||
[ "$FORCE" != 1 ] && [ -f "$1" ] && [ -f "$2" ] &&
|
||||
[ "$(cat "$2" 2>/dev/null)" = "$3" ]
|
||||
}
|
||||
stamp() { echo "$2" > "$1"; }
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# --- c-ares -------------------------------------------------------------
|
||||
# curl's configure wants the headers and the .pc file, not just the archive, so
|
||||
# this prefix outlives the run. It lives under static-deps/, which is ignored,
|
||||
# and it is what makes the skip below safe: without the .pc file a rerun would
|
||||
# point --enable-ares at a prefix that no longer exists.
|
||||
CARES_PREFIX=${CARES_PREFIX:-$OUT/cares-prefix}
|
||||
CARES_STAMP=$OUT/.cares-version
|
||||
if fresh "$OUT/libcares.a" "$CARES_STAMP" "$CARES_VERSION" &&
|
||||
[ -f "$CARES_PREFIX/lib/pkgconfig/libcares.pc" ]; then
|
||||
echo "libcares.a $CARES_VERSION present, skipping (FORCE=1 to rebuild)"
|
||||
else
|
||||
fetch "$WORK/cares.tgz" \
|
||||
"https://c-ares.dev/downloads/c-ares-$CARES_VERSION.tar.gz" \
|
||||
"https://github.com/c-ares/c-ares/releases/download/v$CARES_VERSION/c-ares-$CARES_VERSION.tar.gz"
|
||||
echo "extracting c-ares $CARES_VERSION"
|
||||
tar xzf "$WORK/cares.tgz" -C "$WORK"
|
||||
cd "$WORK/c-ares-$CARES_VERSION"
|
||||
# c-ares 1.34 has no --disable-tools; only the test suite is optional.
|
||||
./configure --prefix="$CARES_PREFIX" \
|
||||
--enable-static --disable-shared \
|
||||
--disable-tests \
|
||||
CFLAGS="-O2 -fno-lto"
|
||||
make -j"$JOBS"
|
||||
make install
|
||||
cp "$CARES_PREFIX/lib/libcares.a" "$OUT/libcares.a"
|
||||
stamp "$CARES_STAMP" "$CARES_VERSION"
|
||||
echo "wrote $OUT/libcares.a"
|
||||
fi
|
||||
|
||||
# --- curl ---------------------------------------------------------------
|
||||
CURL_STAMP=$OUT/.curl-version
|
||||
if fresh "$OUT/libcurl.a" "$CURL_STAMP" "$CURL_VERSION"; then
|
||||
echo "libcurl.a $CURL_VERSION present, skipping (FORCE=1 to rebuild)"
|
||||
else
|
||||
fetch "$WORK/curl.tar.xz" \
|
||||
"https://curl.se/download/curl-$CURL_VERSION.tar.xz" \
|
||||
"https://github.com/curl/curl/releases/download/curl-$CURL_VERSION/curl-$CURL_VERSION.tar.xz"
|
||||
echo "extracting curl $CURL_VERSION"
|
||||
tar xf "$WORK/curl.tar.xz" -C "$WORK"
|
||||
cd "$WORK/curl-$CURL_VERSION"
|
||||
|
||||
set -- --disable-shared --enable-static \
|
||||
--with-ssl \
|
||||
--enable-ares="$CARES_PREFIX" \
|
||||
--without-libpsl --without-brotli --without-zstd --without-nghttp2 \
|
||||
--without-libssh2 \
|
||||
--disable-ldap --disable-ldaps --disable-rtsp --disable-dict --disable-telnet \
|
||||
--disable-tftp --disable-pop3 --disable-imap --disable-smtp --disable-gopher \
|
||||
--disable-mqtt --disable-manual --disable-docs \
|
||||
CFLAGS="-O2 -fno-lto"
|
||||
[ -n "$CA" ] && set -- "$@" --with-ca-bundle="$CA"
|
||||
|
||||
./configure "$@"
|
||||
make -j"$JOBS"
|
||||
|
||||
mkdir -p "$OUT"
|
||||
cp lib/.libs/libcurl.a "$OUT/libcurl.a"
|
||||
stamp "$CURL_STAMP" "$CURL_VERSION"
|
||||
echo "wrote $OUT/libcurl.a"
|
||||
fi
|
||||
Reference in New Issue
Block a user