It's a big difference from your usual terse style! But interesting use
case.

John
-- 
Sent from my Mac 512Ke


On May 19, 2026, Alexander Burger <[email protected]> wrote:

Hi all,

With PicoLisp 26.5.19 there are two new files in the distribution:

1. `lib/ai.l` – a small OpenAI client
2. `lib/vip/ai.rc.l` – a Vip plugin implementing a chat interface

Below is a short overview and some comments on the more interesting
parts of the code.


## 1. Library `lib/ai.l`

This is a minimal JSON/OpenAI client. At the moment it only supports the
`chat/completions` endpoint, but is structured so more endpoints can be
added easily.

 # 13may26 Software Lab. Alexander Burger

 (symbols 'ai 'pico)

 # https://developers.openai.com/api/reference/overview
 # https://platform.deutschlandgpt.de/docs

 (local) (*ApiUrl *ApiKey *Max *Result curl result res api models
completions)

Everything runs in the `ai` namespace, with a few globals:

- `*ApiUrl` – base host, e.g. `api.openai.com/v1/`
- `*ApiKey` – bearer token
- `*Max` – optional `max_tokens` limit (used by the Vip plugin)
- `*Result` – last parsed JSON response as a tree of PicoLisp pairs


### The `curl` wrapper

 (de curl (Res . @)
 (pass list
 "curl" "-s"
 (pack "https://"; *ApiUrl Res)
 "-H" (pack "Authorization: Bearer " *ApiKey) ) )

— `curl` composes an argument list for an external `curl` call.
— `Res` is appended to the base URL (`*ApiUrl`), so the same helper can
 be used for different endpoints (`"models"`, `"chat/completions"`,
 etc.).
— Extra arguments (like `"--json" "@-"`) are passed via `. @`.


### Reading and post-processing the JSON

 (de result @
 (when (setq *Result (readJson))
 (pass res) ) )

 (de res @
 (let (Rest *Result Lst (rest))
 (recur (Lst Rest)
 (loop
 (NIL Lst Rest)
 (setq Rest (get Rest (++ Lst)))
 (T (and (pair Rest) (pair (caar @)))
 (mapcar
 '((Rest) (recurse Lst Rest))
 Rest ) ) ) ) ) )

Here are two interesting aspects:

1. `result`:
 — Reads the entire JSON reply from stdin via `readJson`.
 — Stores it globally in `*Result`.
 — Then calls `res` with any symbol path given (e.g. `'choices
 'message 'content`).

2. `res`:
 — `*Result` is a tree of JSON data (lists and conses).
 — `res` interprets its arguments as a *path* into this tree:
 — `(res 'choices 'message 'content)` walks recursively and collects
 all matching fields.
 — It uses `(get Rest (++ Lst))` and a `recur` to traverse arbitrarily
 nested objects/arrays, and returns a flattened list of all matches.
 — This gives a very compact “query language” over the JSON tree.


### API configuration

 # (api 'file)
 (de api (File)
 (in File
 (setq *ApiUrl (line T) *ApiKey (line T))
 (line T) ) )

— Reads the first two lines of `File`: the API URL and key.
— A third line is read and returned. It may hold a default model, I use
 it in some scripts.
— The Vip plugin expects this to live in `~/.ai`.


### Models listing

 # (models)
 (de models ()
 (in (curl "models")
 (result 'data 'id) ) )

— Calls `GET /models`.
— Extracts all `id` values below `data` using the `result/res`
 mechanism.
— Return value is a list of strings (model IDs).


### Chat completions

 # (completions 'model 'role 'text 'temp ['role 'text 'temp ..]
 (de completions (Model . @)
 (pipe
 (out (curl "chat/completions" "--json" "@-")
 (printJson
 (list
 (cons 'model Model)
 (make
 (link 'messages T)
 (while (args)
 (link
 (make
 (link
 (cons 'role (next))
 (cons 'content (next)) )
 (and (next) (link (cons 'temperature @)))
 (and *Max (link (cons 'max_tokens @))) ) ) ) ) ) ) )
 (result 'choices 'message 'content) ) )

Key points:

— The function interface is compact:
 `(completions "gpt-4.1" "user" "Hello" 2 "system" "You are ..." NIL ...)`
— Arguments are consumed in triples: role, content, temperature.
— `printJson` builds a JSON object like:

 {
 "model": "gpt-4.1",
 "messages": [
 { "role": "user", "content": "Hello", "temperature": 2, "max_tokens": 200
},
 ...
 ]
 }

 (`max_tokens` is injected from the global `*Max` when present.)
— `pipe`:
 — Sends the JSON to `curl` on stdin (`"--json" "@-"`).
 — Then reads the JSON response from `curl`’s stdout and returns all
 `choices.message.content` strings.

So you get a pure PicoLisp function to call the OpenAI chat completions
endpoint, with usable defaults and a simple path mechanism into the
reply.


## 2. Vip plugin `lib/vip/ai.rc.l`

The second file integrates the above into the Vip editor as a mini chat
client bound to a buffer.

 # 16may26 Software Lab. Alexander Burger

 (symbols '(pico)
 (load "@lib/json.l" "@lib/ai.l") )

 (symbols '(ai vip pico))

— Loads the JSON and AI libraries.
— Switches to a combined namespace so `ai`, `vip` and core `pico`
 symbols are all visible.

The plugin defines three commands: `:models`, `:ai`, and `:ai?`.


### `:models` – list model IDs

 (cmd "models" (L Lst Cnt)
 (symbols '(ai vip pico)
 (api "~/.ai")
 (prCmd
 (sort
 (extract
 '((S) (chop (pre? L S)))
 (models) ) ) ) ) )

— Calls `(api "~/.ai")` to set URL and key.
— `(models)` returns all available models.
— `L` is the optional prefix typed after `:models`.
— `pre?` filters only those model IDs starting with that prefix.
— They are sorted and printed with `prCmd` in the Vip command area.


### Conversation file parsing in `:ai`

 (cmd "ai" (L Lst Cnt)
 (symbols '(ai vip pico)
 (api "~/.ai")
 (let
 (Lst
 (split
 (make
 (for L (: buffer text)
 (when
 (or
 (= '("+" "+" "+") L)
 (and
 (head '("+" "+" "+" " ") L)
 (format (cadr (split L " "))) )
 (= '("=" "=" "=") L) )
 (link 0) )
 (link L) )
 0 )
 L (split (caar Lst) " ") )
 (setq
 *Model (pack (car L))
 *Max (format (cadr L)) )
 ...

Interesting details:

— `(: buffer text)` holds all lines of the current buffer.
— The code inserts the marker `0` into the stream whenever it finds:
 — a line `+++` (start of user prompt),
 — a line `+++ <temp>` (prompt with explicit temperature, e.g. `+++
 1`),
 — or a line `===` (start of assistant response).
— Then `(split ... 0)` splits the whole buffer into chunks at these zero
 markers, giving a list of sections, each corresponding to one “block”
 (system messages, prompts, responses).
— `caar Lst` is the first block, which contains the first line: `model
 [max_tokens]`.
 — `L` is that line split by spaces.
 — `*Model` is set from the model name.
 — `*Max` is optionally set from the second item (token limit).

So the *file format* is:

 <model> [max_tokens]
 [optional system text...]

 +++
 <prompt 1>

 ===
 <response 1>

 +++
 <prompt 2>

Temperature is given on the `+++` line, optionally, like:

 +++ 2
 This prompt uses temperature 2


#### Building messages for the API call

Continuing inside `:ai`:

 (let? R
 (or
 (mapcan
 '((S) (split (chop S) "\n"))
 (apply completions
 (mapcan
 '((L)
 (let? S (glue "\n" (cdr L))
 (cond
 ((head '("+" "+" "+") (setq L (car L)))
 (list "user" S (format (cadr (split L " ")))) )
 ((head '("=" "=" "=") L)
 (list "assistant" S NIL) )
 (T (list "system" S NIL)) ) ) )
 Lst )
 *Model ) )
 (mapcar
 '((X) (chop (cdr X)))
 (fish
 '((X)
 (and
 (pair X)
 (memq (car X) '(error message))
 (str? (cdr X)) ) )
 *Result ) ) )

— Each section in `Lst` is a list where:
 — `car L` is the marker line (`+++`, `+++ 1`, or `===`).
 — `cdr L` is the actual multi-line text in that section.
— For each such block:
 — `S` is glued together with `"\n"` to preserve multi-line
 prompts/responses.
 — The role is chosen from the marker:
 — `+++`* → `"user"` and temperature parsed from that line, if
 present.
 — `===` → `"assistant"`.
 — otherwise → `"system"` (the initial text after the first line).
— The resulting triples `(role content temp)` are flattened with
 `mapcan` and passed to `completions` along with `*Model`.

If the API call returns successfully, `R` becomes a list of reply
strings. These are split into lines again so we can paste them nicely
into the buffer.

If there is an error, the second branch:

 (mapcar
 '((X) (chop (cdr X)))
 (fish
 '((X)
 (and
 (pair X)
 (memq (car X) '(error message))
 (str? (cdr X)) ) )
 *Result ) )

— Searches `*Result` for any `(error . "...")` or `(message . "...")`
 string pairs.
— Returns them as lines instead, so the error text is visible in the
 buffer.


#### Pasting the response into the buffer

 (move 'goAbs 1 T)
 (paste
 (make
 (link T (chop (if (res 'error) "???" "===")))
 (chain R)
 (unless (res 'error)
 (and (last R) (link NIL))
 (link (chop "+++") NIL)
 (evCmd
 (cons
 (res 'usage 'total_tokens)
 (res 'choices 'finish_reason) ) ) ) )
 1 )
 (=: buffer fmt *Columns) ) ) ) )

— The cursor is moved to the end (`goAbs 1 T`).
— `paste` inserts:
 — A marker line:
 — `"???"` if there was an error,
 — or `"==="` otherwise (start of assistant’s response).
 — All response lines (`R`).
 — When there is no error, appends:
 — An empty line (if needed),
 — A `+++` marker for the next user prompt.
— Additionally, it calls `evCmd` with `(total_tokens . finish_reason)`
 extracted from the response to show token usage info in the Vip
 REPL.
— Finally, buffer formatting is recalculated (`=: buffer fmt *Columns`).

Thus the buffer remains a self-contained, incremental chat log that can
be re-sent in full at any time.


### `:ai?` – show raw JSON

 (cmd "ai?" (L Lst Cnt)
 (symbols '(ai vip pico)
 (evCmd (pretty *Result)) ) )

— Dumps `*Result` (the last JSON response) as a nicely formatted
 s-expression using `pretty`.
— Useful for debugging, to see all returned fields from OpenAI.


## 3. Conversation file structure (recap)

Per-buffer chat format:

1. First line: model, optionally followed by max token limit

 gpt-4.1-nano 1024

2. Optional system description lines (the “role” / behavior of the
 model).
3. `+++` starts a user prompt.
 — Optionally `+++ N` with integer `N` as temperature.
4. `===` starts an assistant response.
5. `:ai`:
 — Sends the entire conversation so far (system, all prompts and
 responses).
 — Appends the new assistant response plus another `+++` for the next
 prompt.
6. `:models [prefix]`:
 — Shows available models, optionally filtered by prefix.
7. `:ai?`:
 — Shows raw JSON response.

Example session (shortened):

 gpt-4.1-nano

 +++
 What is the capital of Bavaria?

 === ; after :ai
 The capital of Bavaria is Munich.

 +++
 How many inhabitants does it have?

 === ; after another :ai
 As of 2023, Munich has a population of approximately 1.5 million
 inhabitants.

 +++

Have fun! Next endpoint will probably be "responses".

☺/ A!ex

P.S. I produced this mail by editing the attached file "Chat" with Vip
in the way described above. I only changed formatting a little and fixed
two or three minor errors.

Reply via email to