30 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 a97c5b46a4 2.0.0-beta.2
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:54:35 +00:00
HomerandClaude Opus 5.5 aa83bb9783 Harness spec 3.1.0: carried by both implementations
ci / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:37:08 +00:00
HomerandClaude Opus 5.5 8e06590d57 Harness spec 3.1.0: the prefix stays still, in the CLI
ci / check (push) Canceled after 0s
The system prompt is made once per session (start, compaction, model
switch), kept in session_meta and resent byte for byte; it holds no
date. What changes mid-session -- the date, a mode the person chose, a
plan or memory or instruction file they edited, a personality, MCP
instructions that arrived later -- is a <session-note> on the next user
message, stored with it. The tool list is fixed for the session:
plan_submit is always offered and answers unavailable outside plan
mode, the wrap-up keeps the tools and runs no call, a withdrawn tool
stays listed and answers its refusal; the first request waits up to
mcp_wait_ms for MCP servers. Compaction asks for its summary as the
next turn of the same conversation. Cached input tokens are parsed
(incl. llama.cpp timings), stored per request and shown in /context.

Spec: context.md §7/§10, loop.json context.note (+ plan_ended,
plan_own, personal, empty, mcp_wait_ms), modes.json plan.refused,
compact.md without the transcript heading. Conformance area prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:22:02 +00:00
HomerandClaude Opus 5.5 ecae6247fd Harness spec 3.1.0-draft: the prefix stays still
ci / check (push) Canceled after 0s
Measured on 2026-11-05 against a local model's prompt cache: a harness
that rebuilds its system prompt for every request re-reads the whole
session whenever any part changes (a date at midnight: 16,912 tokens,
99 % -> 0 % cached, 139 s). context.md §10: the system prompt is made
once per session and resent; no clock in it; mid-session changes go as
a <session-note> on the next user message (loop.json context.note); the
tool list is fixed for the session, a requires that comes and goes
answers unavailable instead (plan_submit gains requires: plan); §7: the
compaction summary is the next turn of the same conversation, not a
separate request with a pasted transcript; cached tokens are recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:37:25 +00:00
HomerandClaude Opus 5.5 0d04c5300f LLeMbas CLI 2.0.0-beta.1; harness spec 3.0.0
ci / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:50:56 +00:00
HomerandClaude Opus 5.5 a029ee6f07 login --token-stdin: a token a program already holds, checked, then stored as a device login
ci / check (push) Canceled after 0s
`lembas login --token-stdin --server <url> [--ca file] [--json]` reads one lmb_ token from
stdin (never an argument or the environment), checks discovery's protocol and then
GET /api/v1/instance with the token, whose `scope` must include `link` (a 404 or no `scope`
is an older server), and stores the login through the same path as the device code:
the key file, the webui entry, lembas.json, an earlier login replaced with its token signed
out naming the new one. --json prints the device login's `done` / `error` line. For LLeMbas
Desktop, which gets the agent's token from the one approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:48:02 +00:00
HomerandClaude Opus 5.5 059d34cec1 Usage is money per currency, no credits; login asks for memory
ci / check (push) Canceled after 0s
The login asks the server for the memory scope in place of library (the
server accepts both; library is no longer offered for new tokens).

/v1/usage is read in the server's 2.0 shape: no plan, balance, credit
costs or windows. The status bar shows what the month has cost so far and
/usage the month's tokens and cost and this device's tokens, each
currency's amount on its own (month.cost_by_currency, else cost_money in
currency). The credit wording is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:41:11 +00:00
HomerandClaude Opus 5.5 559b521ca9 Harness spec: /v1/usage is money per currency; the device asks for memory
acp.md: GET /v1/usage in the server's 2.0 shape -- month.cost_by_currency,
never summed across currencies, cost_money only with one currency; the
credit fields null and windows empty for a 1.x client and not read by a
3.0.0 one. The device's login asks for models memory link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 03:41:10 +00:00
HomerandClaude Opus 5.5 ef9dcf33c9 Harness spec: conformance names the server's modules; python's limits
ci / check (push) Canceled after 0s
instructions.json and fs_access.json pointed at services/files/*.py,
which LLeMbas never had: the chain is services/workspace.py, the view
services/fs.py with security/access.py. context.json no longer says
"to be written". python's {{seconds}}, {{memory}}, {{output}} and
{{packages}} are implementation-defined; their descriptions record what
LLeMbas 2.0 fills (30 s, 512 MB, 20,000 characters, the standard library
only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:20:42 +00:00
HomerandClaude Opus 5.5 b1dcaded72 Harness spec: one path form, the file tools' texts, the list tree, skills
filesystem.md, spec 3.0.0-draft, and the CLI made to match:

- Paths: absolute in every answer except a result's bracket (context.md),
  which keeps the call's path. glob and grep now give full paths.
- A missing file is `No such file or directory: {path}` for every tool
  that needs it, with no hint after it: in a chat a hidden file must read
  as a missing one. The CLI's "does not exist. Use write to create it." and
  "Write it first." are gone; an edit of a directory is `Is a directory`,
  glob over a file `Not a directory`.
- write/edit refusals, exactly: "{path} exists and has not been read in
  this session. Read it first." and "{path} changed since you last read
  it. Read it again before changing it." (no "on disk"), absolute path.
- A listing sorts names in code-point order; `list` with a depth is a
  tree indented two spaces a level, marks as in a single listing, cut at
  400 lines with a line saying so.
- Skills: where they are found and in what order on each side (the
  session's own place first, the person's next, what is shared last), and
  the name rule ^[a-z0-9][a-z0-9-]{0,63}$ that skill_manage checks. The
  CLI no longer makes names with _ or .; such skills still load.

README's 3.0.0 notes say so; CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:20:42 +00:00
HomerandClaude Opus 5.5 4ca6af12b1 Harness spec: stubs per request, the compaction table from the reads it covers
context.md and loop.json `context`, spec 3.0.0-draft:

- A stub names only a result in the request being built. A chat on the
  server sends no earlier reply's tool results, so a read in the next reply
  is answered in full there; the CLI, which resends every result, keeps
  stubbing across steps. Said for reads and for repeated listings alike.
- The compaction table is drawn from the reads the summary covers: the
  full reads since the previous compaction in turns still in the history,
  whether or not the request still carries them. A turn taken back takes
  its reads with it. The CLI's dedupe table is exactly that set; a chat
  finds it in what it stored.
- Ids are the result's number in the session, in hex, at least 4 digits
  (#0001); the examples and the prompt block now use such ids.
- A bracket names the path as the call gave it; what identifies a file
  is its absolute path.

Conformance `context`: a `reply` step (a request carrying none of the
results so far) and a case for both rules. The CLI's runner keeps the
history apart from the request; compactionTable takes the reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:20:28 +00:00
HomerandClaude Opus 5.5 eacf84fd67 tests: a job's note kept for a closed session, without the race
ci / check (push) Canceled after 0s
"keeps its note until the session is resumed" started `sleep 0.5` and counted
on the turn taking less than that before /new. On a loaded machine (load 7 on
two cores) the turn took longer, the job ended while the session was still
open, and its note went in as a turn instead of waiting. The command now
waits for a file written after /new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:55:14 +00:00
HomerandClaude Opus 5.5 89ceeec159 Comments: refs are served ids, with no protocol-2 or own-connection cases left
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:26:10 +00:00
HomerandClaude Opus 5.5 77161bee35 CHANGELOG: [Unreleased] for 2.0
What a user sees change: the CLI uses the server it is logged in to and
nothing else, models by their served names, memory on the server, stacked
instruction files, short tool results that are never rewritten, recall and
show, link protocol 3, and a chat's model or a refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:18:55 +00:00
HomerandClaude Opus 5.5 ef209f41a1 Link protocol 3; a session the client opens runs on the chat's model or not at all
- LEMBAS_PROTOCOL, the device protocol and the protocols the CLI logs in to
  and links with are 3 alone (harness/acp.md *Versions*). A server listing
  only 1 and 2 is refused at login and at every dial — before anything is
  dialled — with its version and protocols and "update the server, or use
  LLeMbas CLI 1.x with it"; a newer one is told to update the CLI
  (ProtocolError). A 4400 stops the link with the same advice. The old
  downgrade to protocol 1 is gone.
- session/new's result says `instance`: whether the session's model is the
  link's instance's.
- `lembas_model` is the model, full stop: a device not logged in to the link's
  instance, a link with no instance (serve --stdio), or a model the instance
  does not serve to this device is -32602 with the reason, never a session on
  the device's own model. `_lembas/configure` naming such a model is refused
  likewise and the session keeps its model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:18:37 +00:00
HomerandClaude Opus 5.5 d5043ab0db Harness spec 3.0.0-draft: link protocol 3; one server names the models
acp.md *Versions*: LLeMbas 2.0 and LLeMbas CLI 2.0 speak link protocol 3 and
nothing else, so a 1.x peer and a 2.0 one refuse each other and each says what
to update. A 2.0 server's discovery says `protocol: 3, protocols: [3]` —
`protocol` was frozen at 1 so 1.x clients comparing it could log in; raising
it now is the point. A 2.0 CLI logs in and links only where 3 is listed and
otherwise names the server's version and protocols; a 2.0 server closes a
hello of 1 or 2 with 4400 and "update LLeMbas CLI to 2.0".

Model naming: the served id is the device's ref (one server, no connection
prefix, no second login); the older forms still resolve. A configure naming a
model the device cannot run is -32602 too, and the session keeps its model.
initialize's example says 3; `show` in the client's capabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:18:37 +00:00
HomerandClaude Opus 5.5 a73adc01f0 Only memory comes from the server's /mcp; notes, knowledge and skills stay local
Harness spec 3.0.0 (acp.md *Memory*): memory records are the server's for the
device as for a chat; everything else in the library is the device's own.

- `library: lembas` (the default when the login's token has the `memory`
  scope, or the older `library`) adds the server's /mcp with its `memory`
  tool alone, in place of this machine's memory tool; notes_*, knowledge_*
  and skill_* are always this machine's. No prompts, resources or
  instructions are taken from it.
- The memory in the prompt is the server's: the resource lembas://memory,
  read as a session starts (McpManager.readResource) and kept for it — put in
  while nothing has been sent, so the prompt never changes under a session.
- A session the client opened (session/new, or opened from the store) sends
  its id as `_meta.lembas.scope` on every memory call and on the resource
  read (App.setScope); a session of the device's own sends none.
- An instance's `library` context (its knowledge bases) is no longer read:
  the knowledge block names this machine's bases only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:07:37 +00:00
HomerandClaude Opus 5.5 6d5a25cdad Harness spec 3.0.0-draft: the memory resource a device reads, by name
acp.md said a logged-in device reads "the resource holding the entries for
the prompt" without naming it, so neither side could implement it. It is
`lembas://memory`: the entries as the memory block of a system prompt shows
them, read as a session starts and kept for it. Only `memory` is taken from
/mcp — notes, knowledge bases and skills stay the device's; the scope rides on
the resource read as on the tool call; a token's older `library` scope counts
as `memory`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:01:17 +00:00
HomerandClaude Opus 5.5 28ff205ed0 Custom connections are gone: the CLI talks to the LLeMbas server it is logged in to
LLeMbas CLI 2.0 is LLeMbas-server-specific (CLI rule 2). Removed: the
anthropic, gemini, ollama and responses clients, context discovery and
llama-swap unloading (discover.ts), the Connection schema with its dialect,
auth, key_cmd, discover, unload_url and one_model_at_a_time, the model
`fallback` (it only ever moved to another server), effort_map and cache, and
the multi-connection refs. Kept: openai-chat, http, sse, think, effort,
tokens and all of src/lembas.

- connections.yaml holds the login (`type: webui`, as lembas login writes
  it). A custom entry from 1.x is read past with a warning that says how to
  log in, so an old file still starts; with several logins left, the latest
  is used and the others named.
- A model's ref is the id the server serves it under; the older forms
  (`<login>/<id>`, `<login>/<model>`) still resolve and config.yaml is
  rewritten once. Without a login there is no model, and the error (and the
  first-run screen, now the sign-in alone) says `lembas login <address>`.
- A login to another server signs this machine out of the previous one.
- The window is the server's word (no discovery); a capacity group comes
  from the server's `capacity.group` alone; embeddings use the server's
  /v1/embeddings.
- `lembas models` lists the server's models; install.sh's starter files, the
  README and the JSON schemas say the same.

Tests: every fixture is a login now (the fake server lists models, answers
other GETs with 404, and can hand one model to another script, as a server
with several backends); the dialect suites and the cross-connection tests are
gone with their code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 21:00:05 +00:00
HomerandClaude Opus 5.5 f20e8c4307 Context hygiene at insert time: result ids, read stubs, the cap, recall
Harness spec 3.0.0 (context.md, loop.json `context`), in session/results.ts,
applied by the engine when a result goes into the conversation and never
afterwards:

- every result is headed by its id ([#0001]); a read also by the path as
  given, its lines, the file's length and the first 8 hex digits of the
  content's SHA-256; an empty file says so;
- a read of an unchanged file whose lines lie inside an earlier full read is
  a stub naming it; a list/glob/grep repeated with the same arguments and the
  same result is "Same result as #…";
- output over 400 lines or 40,000 characters keeps head and tail with the
  elided line; the full result is kept with the session (a new `results`
  table in sessions.db, gone with the session);
- edit, multiedit, write and apply_patch answer with their hunks under a
  bracket with the new content's hash; a new file is "Created, N lines.";
- recall (new built-in tool) shows any stored result, or a range of it, also
  after a resume; ids are never reused;
- compaction appends the table of the reads still in the conversation to the
  summary.

A stub only ever points at a result still in the conversation, so after a
compaction, an /undo or a rewind the file is read in full. compaction.prune
is gone: cutting old outputs in place threw the prompt cache away (an old
config's key is dropped with a warning). blocks/context.md is in the prompt
wherever recall is offered. Conformance runner for the `context` area (11
cases pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:27:17 +00:00
HomerandClaude Opus 5.5 8cfbfbbca4 Harness spec: name the Harness-parity wiki page without a host
The README linked the page on the forge's own host. It names the page in
LLeMbas CLI's wiki instead, so the spec (vendored by the server) carries no
host name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:13:23 +00:00
HomerandClaude Opus 5.5 e7300b26d5 Harness spec 3.0.0-draft: context ids in the cases are hex; several files in one patch
conformance/context.json named results #g001…#k001, which are not the
lowercase hex context.md requires (an implementation that finds a result's id
at the head of the result by that rule cannot see them); they are #1001…#5001
now, with the same answers. Its `about` names the CLI's module,
session/results.ts.

context.md §5 says what it left open: a deleted file's bracket, and an
apply_patch over several files — the id alone, then each file in its own
bracket with its change, in the patch's order; a tool's note after the diff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:09:22 +00:00
HomerandClaude Opus 5.5 4bbee18cee Instruction files stack from the project root down to the working directory
instructionChain (spec 3.0.0, filesystem.md): from the boundary — the project
root — down to the start directory, outermost first, each directory gives the
first of AGENTS.md, CLAUDE.md that is a file really inside the project; one
that is not (a link out) is passed over and the walk goes on below it. Before,
only the nearest file counted. The chain's files are named to the model by
their path in the project; agentsBlock reads them all: none means "make one",
and git is settled when any of them settles it.

Conformance runner for the `instructions` area (all 9 cases pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:04:03 +00:00
HomerandClaude Opus 5.5 bc78aec424 CLI loads harness spec 3.0.0-draft; the show tool over the link
The spec's version may be a pre-release (3.0.0-draft). Every tool file is
loaded, with the new fields `sessions`, `requires`, `unavailable` and
`confirm`; the CLI's tools are those not the server's alone and offered in an
agent session (forCli) — a chat's own tools over the server's tree (mkdir, mv,
rm, search, python, the forge verbs) are not built in. blocks/context.md is
known; blocks/workspace.md is the server's alone.

show is built in with `requires: display`: offered only in a session a client
opened over the link when that client said `show: true` (capability `show`);
the device reads the file (at most 1 MB) and sends it with `_lembas/show`.
The harness tests now say 3.0.0's split of tools instead of "the execution
tools are the CLI's alone".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:00:02 +00:00
HomerandClaude Opus 5.5 9f536d8345 Harness spec 3.0.0-draft: prompts, the link, and the changelog
ci / check (push) Canceled after 0s
blocks/context.md tells the model about stubs and recall; blocks/workspace.md
tells a chat's model its view. blocks/library.md and blocks/prototypes.md are
the CLI's alone. acp.md: `instance` on session/new, a client-opened session
runs on the chat's model and no other (the own-model notice is gone),
capability `show` with `_lembas/show`, memory through the instance. README:
the new fields and the 3.0.0 section. VERSION 3.0.0-draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:52:24 +00:00
HomerandClaude Opus 5.5 6ca43b6908 Harness spec 3.0.0-draft: context hygiene, at insert time only
context.md and loop.json `context`: every result has an id, a read carries its
lines and content hash, an unchanged re-read (or a range inside one) is a stub,
large output keeps head and tail with the elided line, recall shows any stored
result, edits answer with their diff, a repeated listing or search with the
same result is a stub, and compaction adds a table of reads and empties the
dedupe table. Nothing already sent is ever rewritten. Conformance area context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:52:24 +00:00
HomerandClaude Opus 5.5 f606c5d1e7 Harness spec 3.0.0-draft: the file system as a contract
filesystem.md: paths, the node kinds dir/file/mount, the access levels
none/ro/stage/commit with grants and floors, the exact error texts, and the
rule that a granted path under one that is not is reached only by its full
path. Listings, search, indexing by tokens (480 a piece, 64 repeated),
instruction files stacked from the boundary to the start directory, skills,
and memory kept by the server for both sides. Conformance areas fs_access,
instructions and chunk_tokens, answered from a reference model of the rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:52:24 +00:00
HomerandClaude Opus 5.5 253ef4a8a0 Harness spec 3.0.0-draft: where each tool is offered; the chat's tools
Every tool says its `sessions` (chat, agent or both). The file tools read, write,
edit, glob, grep and list are shared; mkdir, mv, rm, search, python and the forge
verbs (commit, branch, pr, merge, tag, release) are the chat's; show and recall
are new and shared. New fields: `requires` (page, display, forge) with the exact
`unavailable` answer, and `confirm: always` for merge, tag and release.
knowledge_*, notes_search, note_view and note_manage leave the chat. Default
rules for the new tools; modes.json says what `confirm` does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:52:24 +00:00
HomerandClaude Opus 5.5 a03d46cf74 LLeMbas CLI 1.5.2: a login again leaves one device; the link says why it cannot connect
ci / check (push) Canceled after 0s
A second `lembas login` to the same instance overwrote the old token without
signing it out, leaving an offline twin of the device holding its agent chats.
The old token is now signed out over the TLS that just worked, naming the new
one, so an instance that knows `replaced_by` moves the chats; a failed sign-out
is said.

`login --ca` kept the path as typed, so a service started in another directory
dialled with no CA. It is kept in full now, the link trusts what discovery got
through with (the system's bundle included), and "could not reach the instance"
says why: a CA file that is gone, or what discovery met.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:48:50 +00:00
HomerandClaude Opus 5.5 82918ba5a8 LLeMbas CLI 1.5.1
ci / check (push) Canceled after 0s
An image counted as half a million tokens: the context estimate measured its
base64 as text, so a 1-3 MB screenshot read as "context at 150-280%" the
moment view_image added it, and the session compacted after every image.
An image now counts by its dimensions (28 px patches, at most 16k tokens, a
flat 1600 when unreadable) everywhere the CLI estimates -- the meter, pruning,
/context and the no-usage fallback of all five dialects. Images over 1600 px
or 500 KB are shrunk to 1600 px when ImageMagick is there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:24:31 +00:00
186 changed files with 7005 additions and 3295 deletions

No files matched your search

+128 -1
View File
@@ -4,7 +4,134 @@ All notable changes to LLeMbas CLI. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
[Semantic Versioning](https://semver.org/).
## [Unreleased]
## [2.0.0-beta.2] — 2026-11-07
Harness spec 3.1.0: what the model has already read stays exactly as it was, so its server
can reuse it instead of reading the whole session again.
### Changed
- **The system prompt is made once per session** — at the start, after a compaction, on a model
switch — kept with the session and sent byte for byte on every request; a resumed session sends
the one it kept. It holds no date any more.
- **What changes during a session is said in a note** after your words on your next message: the
date (on the first message, and when it changes), a permission mode you chose, a plan you
changed, memory or an instruction file you edited, a personality you picked, an MCP server's
instructions that arrived later. The model's own changes (its plan, its edits, a plan approval)
are not said again. Notes are not shown as your words.
- **The tool list is the same for the whole session.** `plan_submit` is offered in every mode and
answers that it works only in plan mode; `show` is offered from the start where the session can
have a viewer; knowledge search is decided as the session starts. The tools stay listed when a
step limit or budget winds a prompt up (a call in that answer is not run), and a tool refused
for good stays listed and answers that refusal. The first request waits up to 3 seconds for the
configured MCP servers, so their tools are in the first list.
- **Compaction asks for its summary as the next turn of the same conversation** instead of a
separate request with a pasted transcript, so it reuses what the server has read and no longer
loses the middle of a long session. A tool call in the summary's reply is not run.
### Added
- **Cached input tokens are recorded for every request** (the usage's own count, or llama.cpp's
`timings` and progress), kept with the session, and shown by `/context` for the last request.
## [2.0.0-beta.1] — 2026-11-03
LLeMbas CLI 2.0 works with LLeMbas 2.0 and nothing else: it uses the server it is logged in to,
keeps tool results short without ever rewriting what the model has already read, and reads every
instruction file on the way down to where it was started. Harness spec 3.0.0.
### Changed
- **The CLI talks only to the LLeMbas server it is logged in to.** Custom connections are gone:
the OpenAI-compatible, Responses, Anthropic, Gemini and Ollama connections in `connections.yaml`,
with their `dialect`, `key_cmd`, `auth`, `discover`, `unload_url` and `one_model_at_a_time`,
and a model's `fallback`. Log in with `lembas login <address>`; without a login the CLI says so
and how. An old `connections.yaml` still starts: an entry that is not the login is named in a
warning and ignored. One server at a time: logging in to another signs this machine out of the
first.
- **Models are named as the server serves them** (`deepseek/deepseek-flash`), with no connection
in front; a `config.yaml` naming one the old way is rewritten once. The context window,
efforts and capacity are the server's word.
- **Memory is the server's**, shared with your chats there (when the login's token may reach it):
the `memory` tool and the memory in the prompt. Notes, knowledge bases and skills stay on this
machine.
- **Logging in asks for the `memory` scope** in place of `library`; a login made before keeps
working with the one it has.
- **Usage is money, not credits.** The status bar shows what the month has cost so far, and
`/usage` the month's tokens and cost and this device's tokens — each currency's amount on its
own (`€2.00 · $0.75`), never added together. Plans, balances, rolling windows and the
`unlimited` of an administrator are gone with the server's credits.
- **Instruction files stack.** Every `AGENTS.md` (or `CLAUDE.md`) from the project root down to the
working directory goes to the model, outermost first — not only the nearest one.
- **Tool results are kept short when they come in, and never changed afterwards**, so the server's
prompt cache holds. Every result has an id (`[#0001]`); a read says its lines and a hash of the
file; reading an unchanged file again, or the same listing or search with the same result,
answers with a pointer to the earlier result; output over 400 lines or 40,000 characters keeps its
beginning and its end; edits answer with their diff. `compaction.prune`, which cut old outputs
and made the server read the whole conversation again, is removed.
- **The link speaks protocol 3** with a LLeMbas 2.0 server. A 1.x server is refused at login and by
the link, saying to update the server (or use LLeMbas CLI 1.x); a newer one says to update the CLI.
- **A chat started on a model runs on that model.** When the web UI opens a session on this device
with a model the device cannot run, it is refused with the reason instead of being answered on
the device's own model.
- **File tools speak the server's words.** A missing file is `No such file or directory: <path>`
from `read`, `edit`, `multiedit`, `show`, `list`, `glob` and `grep` alike; a file `write` or
`edit` may not change yet says it has not been read, or changed since it was last read, with its
full path. `glob` and `grep` give full paths. `list` sorts names exactly (capitals first) and says
when it stopped at 400 lines.
- **A new skill's name** is lowercase letters, digits and `-`, up to 64 characters, as the Agent
Skills format has it; `_` and `.` are no longer accepted when `skill_manage` makes one. Skills
that already have such a name still load.
### Added
- **`recall`**: the model can see any earlier tool result again, in full or from a line on — the
part of a long output left out, or a result from before the conversation was summarised. After
a summary the model also gets a table of the files it had read.
- **`show`**, in a session the web UI or LLeMbas Desktop opened: the model can open a file it made
— a page, a document — in your viewer beside the chat.
- **`lembas login --token-stdin --server <url>`**: sign in with a token a program already holds —
LLeMbas Desktop, from the one approval that gives it the agent's token beside its own. The token
is read from stdin only, never from an argument or the environment, and checked against the
server first: it must speak protocol 3, accept the token, and say it carries `link`. The login
is then stored exactly as a sign-in by code (an earlier one replaced and its token signed out),
and `--json` prints the same `done` (or `error`) line.
## [1.5.2] — 2026-10-25
Logging in again leaves one device, and a link that cannot connect says why.
### Fixed
- **Logging in again left an offline twin of this device on the instance.** A second
`lembas login` to the same instance overwrote the old token without signing it out, so the web
UI showed the device twice -- the new one linked, the old one offline and holding its agent
chats. The old token is now signed out, naming the new one, and a LLeMbas server 1.7.2 or later
moves the old device's agent chats to it. A sign-out that fails is said, with where to revoke it.
- **`lembas service` could not link when the login was given its CA as a relative path.** `login
--ca` kept the path as typed, and the service, started in another directory, did not find it and
dialled with no CA at all. The path is now kept in full, and a link to an instance whose
certificate is signed by a CA in the system's store gets through even without `--ca`, as the
rest of the CLI already did.
- **"Could not reach the instance" now says why**: a CA file given at login that is gone, a
certificate that is not trusted, or what else the connection met.
## [1.5.1] — 2026-10-23
A screenshot no longer makes the session compact.
### Fixed
- **An image counted as half a million tokens.** The context estimate measured an image's base64
as text, so a 1–3 MB screenshot read as "context at 150–280%" the moment `view_image` (or an
`@` attachment) added it, and the session was compacted -- after every image, since the summary
drops pictures and the model looked again. The provider's own count, a few thousand tokens, was
never in question. An image now counts by its dimensions (28-pixel patches, at most 16k tokens;
a flat 1600 when the size cannot be read), everywhere the CLI estimates: the context meter,
pruning, `/context`, and the estimate a provider that reports no usage falls back to.
- **Screenshots are sent at 1600 px.** Any image over 1600 px or 500 KB is shrunk to 1600 px on
its longer edge when ImageMagick is there (only files over 3.5 MB were before): no more detail
than the model is billed for, and a lighter request.
## [1.5.0] — 2026-10-20
+13 -10
View File
@@ -4,12 +4,11 @@
# LLeMbas CLI
A terminal coding agent and project manager: the `lembas` command. It talks to any LLM API
(OpenAI-compatible servers such as llama.cpp, vLLM or LM Studio, the OpenAI Responses API,
Anthropic, Gemini, Ollama), works in your project with four permission modes and a git snapshot of
every turn, remembers what it learns across sessions, and can sign in to a
[LLeMbas](https://github.com/LLeMbas/LLeMbas) server to use its models, library and search — and
let its web UI work the same sessions. Linux, x64 and arm64.
A terminal coding agent and project manager: the `lembas` command. It signs in to a
[LLeMbas](https://github.com/LLeMbas/LLeMbas) server and uses its models, memory and search, works
in your project with four permission modes and a git snapshot of every turn, keeps its own notes,
knowledge bases and skills on your machine, and lets the server's web UI work the same sessions.
Linux, x64 and arm64.
## Install
@@ -24,12 +23,16 @@ checksum before installing anything ([SECURITY.md](SECURITY.md) shows how to che
## First steps
```sh
cd your-project && lembas # the TUI
lembas login https://your-server # sign in to a LLeMbas server and use its models
lembas login https://your-server # sign in to your LLeMbas server
cd your-project && lembas # the TUI, on the server's models
```
Without a server, add a connection to `~/.config/lembas/connections.yaml` and set `model:` in
`~/.config/lembas/config.yaml`.
A program that already holds an agent's token for this machine (LLeMbas Desktop does, from the
same approval as its own) hands it over on stdin, never as an argument:
`… | lembas login --token-stdin --server https://your-server [--json]`.
From 2.0 the CLI talks only to the LLeMbas server it is logged in to; the custom connections of 1.x
(`dialect:` entries in `connections.yaml`) are gone.
`lembas` updates itself (`lembas update` does it now); `lembas uninstall` removes it, and
`--purge` takes settings and sessions too.
+106 -7
View File
@@ -7,7 +7,7 @@ metadata. This directory writes that design down as data. Each project implement
language; neither ever runs the other. LLeMbas CLI embeds these files at build time; LLeMbas vendors a pinned, hash-checked copy.
The concept-by-concept map of the two implementations, and where they still differ, is the wiki
page [Harness-parity](https://git.houmeres.sk/LLeMbas/LLeMbas-CLI/wiki/Harness-parity).
page *Harness-parity* in LLeMbas CLI's wiki.
## What is here
@@ -15,14 +15,16 @@ page [Harness-parity](https://git.houmeres.sk/LLeMbas/LLeMbas-CLI/wiki/Harness-p
|---|---|
| `VERSION` | The spec's own version, apart from LLeMbas CLI's |
| `purpose.json` | The `purpose` argument added to a tool whose calls say what they are for |
| `tools/<name>.json` | One tool each: name, `scope`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, `variables`, what a project offers of the parameters (`narrow`, per project), `offered` (`subagent`: given to subagents only, never to the main agent — `report`), the description and the JSON Schema of its parameters — exactly what a model is sent |
| `tools/<name>.json` | One tool each: name, `scope`, `sessions`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, `variables`, what a project offers of the parameters (`narrow`, per project), `offered` (`subagent`: given to subagents only, never to the main agent — `report`), `requires` and `unavailable` (when it can be offered, and what a call answers when that has gone), `confirm` (`always`: the person is asked in every mode), the description and the JSON Schema of its parameters — exactly what a model is sent |
| `prompts/` | The prompt texts; `prompts/index.json` says which are shared, which LLeMbas fragment each one is, and what each `{{variable}}` in a text means. `prompts/personality/` holds the personality presets both offer |
| `permission/hardline.json` | The floor: commands refused in every mode, protected paths, and how a command is spelled plainly before it is checked again |
| `permission/arity.json` | How much of a command an "always allow" covers |
| `permission/defaults.json` | The rules every session starts from |
| `permission/modes.json` | The four modes — manual, edit, auto, plan — as what each does with each risk class |
| `models/schema.json` | Model metadata: context, output limit, efforts, vision, tools, notes, capacity |
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits |
| `loop.json` | How one prompt runs: the step backstop, budgets, wrap-up, nudges, parallel calls, capacity; `jobs` — background work, its marks and limits; `context` — result ids, stubs, the output cap, the compaction table |
| `filesystem.md` | The file system a session works in: paths, node kinds, the four access levels, errors, listings, search and indexing by tokens, stacked instruction files, skills, memory |
| `context.md` | Context hygiene: what happens to a tool result when it is inserted, and never afterwards |
| `quirks.json` | What real servers do, and how both harnesses cope |
| `conformance/<area>.json` | Cases with their expected results, run by both test suites |
| `acp.md` | The link between them: ACP plus every `_lembas/*` method, field and capability |
@@ -35,17 +37,112 @@ page [Harness-parity](https://git.houmeres.sk/LLeMbas/LLeMbas-CLI/wiki/Harness-p
- **Scopes:** `shared` — both projects implement it; `cli` or `llembas` — only that one. The same
two names key what is per project elsewhere: a tool's `aliases`, `loop.json`'s budgets, a
protected path's `scope`.
- **Sessions** (a tool's `sessions`, spec 3.0.0): where a tool is offered — `chat`, a conversation
on the server, working in the server's file system (`filesystem.md`); `agent`, a session on a
device (the CLI's own, Desktop's agent — which is the CLI — or an agent chat the web UI runs on a
linked device), working on that machine's disk; `both`. The two say different things: `read` is
`shared` and `both` (each project implements it for its own world); `bash` is `cli` and `agent`;
`mkdir` is `llembas` and `chat`.
- **Requires** (spec 3.0.0): what must hold for a tool to be offered at all — `page`: a browser
page of the chat is open and attached (not a schedule, not a background job); `display`: the
session is shown somewhere a file can be opened (the web UI, Desktop; not a bare terminal);
`forge`: the session's view holds a repository mounted from a forge; `plan`: the session is in
plan mode. A requirement that goes away between the offer and the call makes the call answer the
tool's `unavailable` text, word for word, as its result. **From 3.1.0 a requirement that can come
and go during a session does not take the tool out of the list** (`context.md` §10, rule 4): the
tool is offered from the start and answers `unavailable` while the requirement is not met.
- **Confirm** (spec 3.0.0): `always` — the call is put to the person before it runs, in every mode,
auto included, and runs on a yes only; nothing remembers the answer (`permission/modes.json`).
- A tool's other names, per project, are listed in its `aliases` and work as the tool. A former
name may have taken different arguments; the project converts them. The one such case:
**`todo` → `plan_write`**, `todos[]` becoming one phase called "Work" (`pending` → `todo`,
`in_progress` → `doing`, `completed` → `done`, `cancelled` → `dropped`; `conformance/plan.json`).
- **The plan** (`plan_write`, `plan_update`, `plan_submit`) is one structure both keep per session —
title, summary, findings, objectives, phases of tasks with a status each, ids given by the
implementation and never by the model — shown as a card in every UI and put into the prompt
(`{{plan_context}}`) on every step while it exists. `plan_write` and `plan_update` are offered in
every mode; `plan_submit` only in plan mode, where it is the approval. Their permission key is
implementation and never by the model — shown as a card in every UI. From 3.1.0 it is in the
system prompt (`{{plan_context}}`) only as it was when the prompt was made; the model sees it change
in its own `plan_write` / `plan_update` results, and a change by the person comes as a note
(`context.md` §10). All three are offered in every mode; `plan_submit` (`requires: plan`) is the
approval in plan mode and answers its `unavailable` text outside it. Their permission key is
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
## 3.1.0
Written ahead of both implementations as `3.1.0-draft`, and `3.1.0` once both carried it (LLeMbas CLI 8e06590, LLeMbas 48e905f). **The prefix stays still** (`context.md` §10). Found on 2026-11-05 by measuring a local model's
prompt cache: a harness that rebuilds its system prompt for every request re-reads the whole session
whenever any part of it changes — the date at midnight, a permission mode, a memory, the plan. So:
the system prompt is made once per session and resent byte for byte; no clock is in it; what
changes during a session comes as a `<session-note>` on the next user message (`loop.json`
`context.note`); the tool list is fixed for the session (`requires` that can come and go no longer
remove a tool — `plan_submit` gains `requires: plan` and an `unavailable` text); compaction asks
for its summary as the next turn of the same conversation instead of a separate request with a
pasted transcript (§7); every request records its cached input tokens.
## 3.0.0
Written ahead of both implementations as `3.0.0-draft`, and `3.0.0` once both carried it (LLeMbas
CLI 2.0.0-beta.1, LLeMbas 2.0.0-beta.1). **Two worlds, one harness**: a chat works in the server's file
system, an agent session on its device's disk, and neither sees the other's files; what is shared is
this design.
- **`sessions`, `requires`, `unavailable`, `confirm`** — new fields on a tool (Vocabulary). Every
tool says its `sessions`.
- **New tools.** `mkdir`, `mv`, `rm` (to the trash) and `search` (meaning and words over the files
this session can see, filtered by its view after scoring) — LLeMbas, chat. `show` (a file in the
user's viewer: Markdown, code, text, HTML in a sandboxed frame) — shared, `requires: display`: a
chat in the web UI, an agent session through the link to the web UI or Desktop (`acp.md`
`_lembas/show`), never a bare terminal. `python` (Pyodide in the user's browser) — LLeMbas, chat,
`requires: page`; with no page it answers *Python cannot run now: no open browser page is attached
to this chat.* The forge verbs `commit`, `branch`, `pr`, `merge`, `tag`, `release` — LLeMbas,
chat, `requires: forge`; `merge`, `tag` and `release` `confirm: always`. `recall` — shared, both
(`context.md`). An agent session keeps git through `bash`, as before.
- **The file tools are shared.** `read`, `write`, `edit`, `glob`, `grep` and `list` are `shared` and
offered in both: on the device's disk, and over the server's tree in a chat. `multiedit`,
`apply_patch` and `bash` stay the agent's. A chat's listing marks a mount and a read-only entry
(`filesystem.md`, told to the model in `blocks/workspace.md`). `skills_list`, `skill_view` and `skill_manage` work over skill files on both
sides; `memory` is the same tool on both, and its records are the server's for both
(`filesystem.md`, *Memory*; `acp.md`, *The HTTP API*). `session_search` is shared.
- **Removed from chat** (LLeMbas): `knowledge_search`, `knowledge_get`, `notes_search`,
`note_view`, `note_manage` — now `cli` and `agent` only: the CLI keeps its local library — and
LLeMbas's own `report_*`, `scratch_write`, the prototype tools and the canvas, which were never
in the spec. Notes, reports and knowledge become files; prototypes become `show`.
`blocks/library.md` and `blocks/prototypes.md` are the CLI's alone.
- **`filesystem.md`** — the file system as a contract: paths, the kinds `dir`, `file`, `mount`, the
levels `none`, `ro`, `stage`, `commit`, grants and floors, the exact error texts, and the rule
that a granted path under one that is not is reached only by its full path, its ancestor denied
for reading and writing and never listed. Paths absolute in every answer but a result's bracket;
one `No such file or directory` for every tool, the `write`/`edit` refusals' texts, the `list`
tree; where skills are found and in what order, and the name rule
(`^[a-z0-9][a-z0-9-]{0,63}$`). Search results and **indexing by tokens** (at most 480
tokens a piece, 64 repeated; a piece the embedder refuses is split, never dropped).
**Instruction files stack** from the boundary down to the start directory, outermost first, one
per directory (`AGENTS.md`, else `CLAUDE.md`), none from a directory the session cannot read —
the CLI took only the nearest one before. `blocks/workspace.md` (LLeMbas) tells a chat's model its
view.
- **`context.md`** — context hygiene, insert-time only, nothing sent ever rewritten: every result
has an id (`#0001`: its number in the session, in hex, at least 4 digits); a read carries its
lines and content hash, under the path as the call gave it; an unchanged re-read, or a range
inside an earlier unchanged read, is a stub — naming only a result in the request being built; output over 400 lines or 40,000 characters keeps its
head and tail with `[N lines elided, full output #id]`; `recall` shows any stored result; edits
answer with their diff; the same listing or search with the same result is a stub; compaction
adds a table of the reads it covers (those since the previous compaction, in turns still in the
history) and empties the dedupe table; earlier reasoning is left to the chat
template. `loop.json` `context` holds the texts and numbers, `blocks/context.md` tells the model.
- **`acp.md`**: **link protocol 3** — LLeMbas 2.0 and LLeMbas CLI 2.0 speak it alone, and a 1.x peer
and a 2.0 one refuse each other, each saying what to update (discovery's `protocol` is raised to 3
on purpose); the served id is the device's ref for a model (one server, no connection prefix);
`session/new`'s result carries `instance`; a session the client opened runs on the
chat's model and no other — refused rather than answered on the device's own, so the "answered on
its own model" notice is gone; capability `show` with `_lembas/show`; memory through the
instance; a 3.0.0 instance sends neither `library` nor `prototypes`. `GET /v1/usage` counts
and prices without credits: the month's cost per currency (`month.cost_by_currency`), never
summed across currencies; the credit fields are null. The device asks for the `memory` scope.
- **Permissions**: default rules for the new tools (`search`, `recall`, `show`, `python` allowed;
the forge verbs asked); `modes.json` `confirm`.
- **Conformance**: `fs_access`, `instructions`, `context`, `chunk_tokens` — new, their answers
written from a reference model of the rules, not recorded from either implementation (neither has
them yet). `chunk` stays, noted as the character rule.
## 2.0.0
Background work is one thing in both projects: a **job**, owned by a session, is a command run in
@@ -126,7 +223,9 @@ narrower tool's facts through its `variables`.
## Changing it
1. Change the spec first, and bump `VERSION`: **patch** for a text or a new conformance case,
**minor** for a new tool, rule or field, **major** for a rename or a removed tool. A tool folded
**minor** for a new tool, rule or field, **major** for a rename or a removed tool — removed
from either project. A spec written ahead of its implementations carries a pre-release suffix
(`3.0.0-draft`) until both have it. A tool folded
into another, its name kept as an alias that still runs, is minor: nothing stored or trained
breaks.
2. A fix comes with a **conformance case** that fails without it. In LLeMbas CLI,
+1 -1
View File
@@ -1 +1 @@
2.0.0
3.1.0
+102 -55
View File
@@ -13,24 +13,31 @@ change to the protocol is a change here first, then in both projects.
## Versions and discovery
- **`protocol`** (`_meta.lembas.protocol` in `initialize`, `protocol` in `_lembas/hello`):
**2**. CLI `src/acp/agent.ts:LEMBAS_PROTOCOL`, LLeMbas
**3**. CLI `src/acp/agent.ts:LEMBAS_PROTOCOL`, LLeMbas
`services/device_link.py:PROTOCOL`.
- **Discovery** (`/.well-known/lembas.json`) says `protocol: 1` **and** `protocols: [1, 2]` —
`protocol` stays 1 on purpose, because a client that compares it for equality at login must
still get in. The CLI reads `protocols` (falling back to `protocol` where it is absent) at
login and again **every time the link dials**, keeps it with the login, and says protocol 2 in
`hello` and `initialize` only to an instance that lists 2; to any other it says 1 (an instance that
speaks only 1 closes a link whose hello says 2 with **4400**). A link closed with 4400 stops — it is not
redialled — saying *the instance and this CLI speak different link protocols — update one of them*.
- **A protocol 1 peer keeps working.** Every feature beyond protocol 1 is named by a **capability
string** in `initialize`'s `_meta.lembas.capabilities`, and **LLeMbas never calls a method, nor
relies on a field, the device did not list**. The CLI logs in to an instance whose discovery says
protocol 1 or 2.
- **3 is LLeMbas 2.0 and LLeMbas CLI 2.0, and they speak it alone** (spec 3.0.0). A 2.0 server keeps
the server's file system, memory and models, and a 2.0 CLI talks to nothing else, so a 1.x peer
and a 2.0 one **refuse each other**, each saying what to update:
- **Discovery** (`/.well-known/lembas.json`) of a 2.0 server says `protocol: 3` **and**
`protocols: [3]`. `protocol` was frozen at 1 for 1.x so that a client comparing it for equality
could still log in; 3 raises it on purpose, so a 1.x CLI's login is refused there (it says the
server speaks protocol 3 and to update the older of the two).
- A **2.0 CLI** logs in only where discovery lists 3 (`protocols`, falling back to `protocol`).
To a server that does not, at login and again at every dial of the link, it says *&lt;server&gt;
runs LLeMbas X, which speaks link protocol 1 and 2; this LLeMbas CLI needs a LLeMbas 2.0 server
(protocol 3) — update the server, or use LLeMbas CLI 1.x with it*, and the link stops.
- A **2.0 server** closes a link whose hello says 1 or 2 with **4400** and the reason *update
LLeMbas CLI to 2.0*; a 1.x CLI stops on 4400 without redialling. A 2.0 CLI's hello says 3, and
a 1.x server closes it with 4400 likewise; the CLI stops and says the server needs updating.
- **Within protocol 3** every feature is still named by a **capability string** in `initialize`'s
`_meta.lembas.capabilities`, and **LLeMbas never calls a method, nor relies on a field, the
device did not list**.
- A client says it is LLeMbas with `clientCapabilities._meta.lembas` in `initialize`, and which
protocol it speaks with `_meta.lembas.protocol` (absent: 1 — a protocol 1 client sends `lembas: true`).
Only such a client gets `_lembas/event`; only one that says protocol **2 or later** gets
`_lembas/question` and `_lembas/plan`, and one that then answers them with method-not-found is
treated as protocol 1 for the rest of the link.
`_lembas/question` and `_lembas/plan` (a 2.0 server says 3), and one that then answers them with
method-not-found is treated as protocol 1 for the rest of the link — what an editor on
`serve --stdio` is.
| Capability | What it promises |
|---|---|
@@ -52,17 +59,18 @@ change to the protocol is a change here first, then in both projects.
| `turns` | `_meta.lembas.turnId` on `session/prompt`, deduplicated; `turnId` on `prompt`/`task` events; turn fields in `status` |
| `shell` | `shell` and `integration` in `_lembas/terminal/open`'s answer (listed only with `terminal`) |
| `context` | `system_prompt` and `personalization` in `session/new` and `session/prompt` `_meta.lembas` are read |
| `library` | `library` in `session/new` and `session/prompt` `_meta.lembas` is read; `_meta.lembas.scope` sent on the instance library's `tools/call` |
| `prototypes` | `prototypes` in `session/new` and `session/prompt` `_meta.lembas` is read |
| `library` | `library` in `session/new` and `session/prompt` `_meta.lembas` is read; `_meta.lembas.scope` sent on the instance library's `tools/call`. Spec 3.0.0: an instance's chats have no knowledge bases, so a 3.0.0 instance sends no `library`; the scope is still sent on its `memory` calls (*The HTTP API*) |
| `prototypes` | `prototypes` in `session/new` and `session/prompt` `_meta.lembas` is read. Spec 3.0.0: a 3.0.0 instance sends none (`show` replaces prototypes) |
| `plan_state` | the structured plan: `_meta.lembas.plan` on the `plan` session update and on `_lembas/plan`; `plan` in `_lembas/session/status` |
| `rewind` | `_lembas/session/rewind` |
| `show` | spec 3.0.0: `_lembas/show`; the `show` tool is offered in a session the client opened when the client said `show: true` in `clientCapabilities._meta.lembas` |
| `jobs` | background work (spec 2.0.0): the `job` and `job_note` events; `_lembas/jobs`, `_lembas/job/output`, `_lembas/job/stop`, `_lembas/job/send`, `_lembas/job/transcript`; `jobs` in `_lembas/session/status`; `job` on `session/request_permission`; `machine` on a `prompt` event |
## Standard ACP, as used here
### `initialize` — client → agent
Params: `{ protocolVersion: 1, clientCapabilities: { _meta?: { lembas?: { protocol: 2 } | true } } }`.
Params: `{ protocolVersion: 1, clientCapabilities: { _meta?: { lembas?: { protocol: 3, show?: true } | true } } }`.
Result:
@@ -75,10 +83,10 @@ Result:
"mcpCapabilities": { "http": false, "sse": false }
},
"authMethods": [],
"agentInfo": { "name": "LLeMbas CLI", "version": "1.3.0" },
"agentInfo": { "name": "LLeMbas CLI", "version": "2.0.0" },
"_meta": { "lembas": {
"protocol": 2,
"harness_spec": "1.1.0",
"protocol": 3,
"harness_spec": "3.0.0",
"limits": { "roots": ["~/work"], "max_mode": "edit" },
"connection": "example",
"capabilities": ["directories", "configure", "…"]
@@ -87,32 +95,39 @@ Result:
```
`limits` is `null` on `serve --stdio`. `connection` is the device's name for the instance — the
login's connection — or `null`; an instance that does not speak protocol 2 recognises its own
models by it, as `<connection>/<name>`, while the `model` event and `announce` say it directly
(`instance`).
login's connection — or `null`; the `model` event and `announce` say directly whether a model is
the instance's (`instance`).
**How the device names an instance's models.** An instance that speaks protocol 2 serves every model
as `<provider>/<model>` (`deepseek/deepseek-flash`), and that served id **is the device's ref** for
it — each provider is, as the person sees it, a connection of its own, backed by the instance
(requests still go to its `/v1`, with the served id). Exceptions: a connection of the device's own
with the provider's name keeps the short form, and the instance's model is then
`<login>/<provider>/<model>`; with two instances serving one provider, the one logged in to first
keeps the short form and the other's are `<login>/<provider>/<model>`; an instance that does not
speak protocol 2 serves bare ids, which stay `<login>/<id>`. The provider is the model's `provider` field where the
instance gives one, else — only from an instance whose discovery lists protocol 2 — the part of the
id before its first `/`; an id with a `/` from any other instance is just an id (`<login>/<id>`). The old forms (`<login>/<model>`,
`<login>/<provider>/<model>`) still name the same model. `lembas_model` in `session/new` and
`model` in `_lembas/configure` are the instance's served name, as it serves it.
**How the device names an instance's models.** The served id **is the device's ref** for a model
(`deepseek/deepseek-flash`; spec 3.0.0: a 2.0 device talks to one server, the one it is logged in
to, so there is nothing to tell apart — no connection prefix, no second login, no connection of
the device's own). The older forms (`<login>/<id>`, and from before providers `<login>/<model>` for
the one served `<provider>/<model>` of that name) still name the same model. `lembas_model` in
`session/new` and `model` in `_lembas/configure` are the instance's served name, as it serves it.
`model` in a `model` event and in a result is the device's ref, which is the served id.
### `session/new` — client → agent
Params: `{ cwd: <absolute>, mcpServers?: [], _meta?: { mode?, lembas_model?, model?, effort?, lembas?: Context } }`.
`cwd` must be a directory inside `remote.roots`, in a trusted project when `remote.require_trust`
(the default) — otherwise `-32602` with the reason. `mode` is lowered to `remote.max_mode`.
`lembas_model` is the instance's name of a model (it becomes `<connection>/<name>` here); `model`
a ref of the device's own; `effort` a level or `"off"`.
`lembas_model` is the instance's name of a model; `model` a ref the device knows (an editor's
choice); `effort` a level or `"off"`.
Result: `{ sessionId: "ses_…", modes: { currentModeId, availableModes }, _meta: { lembas: { model, mode, effort } } }`.
Result: `{ sessionId: "ses_…", modes: { currentModeId, availableModes }, _meta: { lembas: { model, mode, effort, instance } } }`.
`instance` (spec 3.0.0) is a boolean, with the `model` event's meaning: true exactly when the
session's model is one this link's instance serves.
**The chat's model, full stop** (spec 3.0.0). A session the client opens with `lembas_model` runs on
**that** model and on no other: the device takes it as given, for every turn of the session, until
the client changes it (`_lembas/configure`). If the device cannot speak to it — the instance does not
serve it to this device, or this device is not logged in to the link's instance — `session/new`
fails with `-32602` and the reason (so does a `_lembas/configure` naming such a model, and the
session keeps the model it had), and a turn that loses the model midway fails with its error; the
device never answers on a model of its own instead, and never falls back on its own. So `instance`
is true for every session the client opened, and a client has nothing to say about a device having
"answered on its own model": that notice is gone. A session a terminal shares (`announce`) keeps the
terminal's model, and its `instance` says which it is.
The id is the device's store id and lasts: a session the client names later that is not in memory
is opened from the store under the same rules (capability `sessions`). With capability `commands`,
an `available_commands_update` follows the answer.
@@ -375,6 +390,18 @@ in that summary — and answers `{ reverted: <turns> }`. **Files are not
touched**: what the turns changed on disk stays, and the client says so beside the control. The
client then sends the new prompt as a new turn.
### `_lembas/show` — agent → client → result (capability `show`)
The `show` tool (spec 3.0.0), in a session the client opened, where the client said `show: true`:
the device sends the file, since the client cannot reach the device's disk. Params
`{ sessionId, toolCallId, path, kind: "markdown" | "code" | "text" | "html", language?, line?, text }`
— `path` relative to the session's working directory, `kind` from the file's name (`.md` markdown;
`.html`/`.htm` html; a known source extension code, with `language`; anything else that is text,
text), `text` the content, at most 1 MB (a larger file is refused before sending, and the model is
told). The client shows it in its viewer beside the chat — the web UI, or Desktop's — and HTML only
in a sandboxed frame that reaches neither the app nor the instance. Result `{ shown: true }` or
`{ shown: false, reason }`, which the tool answers the model with.
### `_lembas/session/compact` — client → agent → result
Params `{ sessionId }`. Result `{ summary }`. `-32602` while it works.
@@ -519,21 +546,27 @@ Besides `/v1/models`, the chat completions, `/api/v1/instance` and `/mcp`:
as `<connection>/<served id>` when no `embedding:` is set, and never replaces one that is;
`embedding: <served id>` also finds it. Voice uses the instance's own speech endpoints, not these
entries. Any other kind is ignored.
- **`GET /api/v1/instance`** (the `models` scope) →
`{ service, version, name, connection, account: { email, name }, scope, default_model, services }`.
**`scope`** is what the token presented may do, space-separated, as its login granted it
(`models memory link`). `lembas login --token-stdin --server <url>` takes a token another
program already holds (LLeMbas Desktop: the `agent_token` of the one approval, App API) on
stdin, never from an argument or the environment, and proves it here before writing anything:
discovery must list protocol 3 (else the device login's own refusal), a 401 is a token the
instance does not accept, a 404 or an answer without `scope` an older server, and a `scope`
without `link` is refused. Then the login is stored exactly as a device login's, and `--json`
prints the device login's `done` line: `{ event: "done", fresh: true, connection, base_url,
version, models, changes }` (or `{ event: "error", message }`).
- **`GET /v1/usage`** →
`{ plan: { name, credits_per_month } | null, balance: number | null, month: { from, tokens_in, tokens_out, cost: number | null }, device: { tokens_in, tokens_out, cost: number | null }, unit: "credits" }`.
The CLI shows the balance in its status bar when `plan` is not null, and the whole of it under
`/usage`. A 404 (an instance without it) shows nothing.
Also read, every one optional (an instance without them gets credits alone):
`currency` (`"EUR"` | `"USD"` | `"GBP"`), `credit_value` (money per credit), `admin` (boolean:
no limit, never refused), `balance_money`, `month.cost_money` and `device.cost_money` (numbers or
`null`), and `windows: [{ window, limit, spent, frees_at }]` (rolling spending windows: `window`
names it, `limit` and `spent` in credits, `frees_at` an ISO time or `null`). Credits are shown
with up to two decimals below 1,000 and whole above; beside each, its money in `currency` where
given (`€1.23`, `$1.23`, `£1.23`) — `*_money` when sent, else the credits times `credit_value`.
`admin: true` shows `unlimited` in the status bar whatever the plan, and `/usage` says so, still
listing the month's and the device's spend; its windows are not listed. Otherwise `/usage` lists
each window's spend, limit and when it frees.
`{ month: { from, tokens_in, tokens_out, cost_money: number | null, cost_by_currency: { [currency]: number } }, device: { tokens_in, tokens_out }, currency, admin, unit: "money" }`.
Counting and money, no credits (spec 3.0.0): `cost_by_currency` is the month's cost in each
currency the account's use was priced in (a connection prices in its own; `{}` when nothing was
priced), and two currencies are never added together. `cost_money` is that cost when exactly one
currency is involved, in `currency`; with several it is `null`. `plan`, `balance`,
`balance_money`, `month.cost`, `device.cost` and `device.cost_money` are still sent, as `null`,
and `windows` as `[]`, for a 1.x client; a 3.0.0 client does not read them. The CLI shows the
month's cost (each currency's amount, `€2.00 · $0.75`) in its status bar when anything was
priced, and the whole of it under `/usage`. A 404 (an instance without it) shows nothing.
- **`GET /v1/me/personalization`** →
`{ enabled, available, personality, personality_custom, instructions }`; **`PUT`** the same fields
(403 when personalization is not `available` **or** the token is not a device's token — the
@@ -545,18 +578,32 @@ Besides `/v1/models`, the chat completions, `/api/v1/instance` and `/mcp`:
`enabled: true` — never the session's copy of the other two, which may be blanked or mapped — so
the instance keeps what it has saved. LLeMbas must accept a partial `PUT`. Logged out, the local
keys apply.
- **The library**: logged in to one instance whose token has the `library` scope, a config that sets
no `library:` uses the account's (`lembas`); `library: local` written stays local.
- **The library**: logged in to one instance whose token has the `memory` scope (or the older
`library`), a config that sets no `library:` uses the account's (`lembas`); `library: local`
written stays local. The device's login asks for `models memory link`.
**Scope** (capability `library`): on a `tools/call` to `/mcp` made for a session the client opened,
the device sends `params._meta.lembas.scope: <sessionId>`. The instance then answers within that
chat's scope — its knowledge bases (`conformance/library_scope.json` for how `base` is read
against them) and its data group — and refuses (`-32602`) a session id that is not one of this
device's. Without `scope`, the account's default data group, as before.
- **Memory** (spec 3.0.0): memory records are the instance's, for the device as for a chat. Logged
in to an instance whose token has the `memory` scope, the CLI's `memory` tool and the memory it
puts into the prompt are the instance's records, read and written through `/mcp`: the tool
`memory` (the spec's, `tools/memory.json`), and the resource **`lembas://memory`**, whose text is
the entries as the memory block of a system prompt shows them (empty: none), which the device
reads as a session starts and keeps for that session (the prompt does not change under a
session). Only `memory` is taken from `/mcp`: notes, knowledge bases and skills stay the device's
own. A call made for a session the client opened carries `params._meta.lembas.scope:
<sessionId>` (on the tool call and the resource read alike) and lands in that chat's data group;
a session of the device's own lands in the data group of the session's model. A token with the
older `library` scope counts as `memory`. Local memory files are not synced up; logged out, there
is no memory.
## Notes
- `_lembas/files/search`'s `root` is the session's working directory, which `@path` mentions
resolve against (for a session opened at the project root, the project root). `/plan` without a
task is an action, with a task a prompt run in plan mode. Questions and plans also stop waiting
after the device's `remote.approval_timeout`, like approvals.
- The CLI's login accepts an instance whose discovery says protocol 1 or 2.
- The CLI's login accepts an instance whose discovery lists protocol 3 (*Versions*).
+1 -1
View File
@@ -1,5 +1,5 @@
{
"about": "Text → the pieces a knowledge document is cut into for search: about `size` characters (raised to 200 when smaller), cut just after a paragraph break, else a line break, else a sentence's \". \", found in the window's last third, with `overlap` characters (at most half the size) repeated; CRLF read as LF; pieces under 40 characters dropped.",
"about": "Text → the pieces a knowledge document is cut into for search: about `size` characters (raised to 200 when smaller), cut just after a paragraph break, else a line break, else a sentence's \". \", found in the window's last third, with `overlap` characters (at most half the size) repeated; CRLF read as LF; pieces under 40 characters dropped. Spec 3.0.0: indexing for search cuts by tokens instead (chunk_tokens.json, filesystem.md); this character splitter stays the rule wherever a piece is still measured in characters.",
"cases": [
{
"size": 200,
+77
View File
@@ -0,0 +1,77 @@
{
"about": "Text → the pieces a file is cut into for search, measured in tokens (filesystem.md, Indexing, by tokens): at most `size` tokens each (raised to 16 when smaller), the next one beginning at the start of the token `overlap` tokens (at most half the size) before the cut; the cut just after a paragraph break, else a line break, else a sentence's \". \", looked for in the window's last third (by characters), else at the end of the window's last token; CRLF read as LF, the text trimmed, every piece trimmed; pieces under 40 characters dropped; a text of at most `size` tokens is one piece. `tokenizer: words` is the stand-in for an embedder's: a token is a run of non-whitespace characters. LLeMbas: services/library/chunks.py (to be written); CLI: library/chunks.ts (to be written).",
"cases": [
{
"tokenizer": "words",
"size": 480,
"overlap": 64,
"text": "One short paragraph that fits in a single piece.",
"expect": [
"One short paragraph that fits in a single piece."
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 4,
"text": "The first paragraph is here, with nine words in it.\n\nThe second paragraph follows it and has ten words in total.\n\nA third one closes the text with a few more words.",
"expect": [
"The first paragraph is here, with nine words in it.\n\nThe second paragraph follows it and",
"paragraph follows it and has ten words in total.\n\nA third one closes the text with"
]
},
{
"tokenizer": "words",
"size": 20,
"overlap": 5,
"text": "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega. Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega.",
"expect": [
"Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon",
"pi rho sigma tau upsilon phi chi psi omega. Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda",
"eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega."
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 4,
"text": "line one has four\nline two has four\nline three has five words\nline four has five words\nline five is the last one here\n",
"expect": [
"line one has four\nline two has four\nline three has five words",
"three has five words\nline four has five words\nline five is the last one here"
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 40,
"text": "w0 w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12 w13 w14 w15 w16 w17 w18 w19 w20 w21 w22 w23 w24 w25 w26 w27 w28 w29 w30 w31 w32 w33 w34 w35 w36 w37 w38 w39 w40 w41 w42 w43 w44 w45 w46 w47 w48 w49 w50 w51 w52 w53 w54 w55 w56 w57 w58 w59",
"expect": [
"w0 w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12 w13 w14 w15",
"w8 w9 w10 w11 w12 w13 w14 w15 w16 w17 w18 w19 w20 w21 w22 w23",
"w16 w17 w18 w19 w20 w21 w22 w23 w24 w25 w26 w27 w28 w29 w30 w31",
"w24 w25 w26 w27 w28 w29 w30 w31 w32 w33 w34 w35 w36 w37 w38 w39",
"w32 w33 w34 w35 w36 w37 w38 w39 w40 w41 w42 w43 w44 w45 w46 w47",
"w40 w41 w42 w43 w44 w45 w46 w47 w48 w49 w50 w51 w52 w53 w54 w55",
"w48 w49 w50 w51 w52 w53 w54 w55 w56 w57 w58 w59"
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 4,
"text": "a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd ",
"expect": [
"a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d",
"a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd"
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 4,
"text": " \r\n ",
"expect": []
}
]
}
+480
View File
@@ -0,0 +1,480 @@
{
"about": "Context hygiene (context.md, loop.json `context`): what is inserted into the conversation for each step of a session, in order. Steps: `read` (`path`, the file's whole `content`, `offset`, `limit` — the read tool's body: `<n>: ` lines, the note when lines remain), `edit` / `write` (`path`, `before` — absent for a new file — and `after`), `output` (any other tool's result: `tool`, `args`, `text`), `recall` (`target`, `offset`, `limit`), `reply` (a new reply begins whose request does not carry the results inserted so far — a chat's next reply: they stay in the history a compaction covers, but no stub may name them) and `compact` (the table added after a summary, from the full reads since the previous compaction in the history; the request and the dedupe table are emptied). Each step but `reply` and `compact` carries the `id` the session gives its result; `reply` answers \"\", and so does a `compact` with no reads. `limits` overrides loop.json's `cap` for the case. The answer is the text each step inserts: a read headed by path, lines, length and the first 8 hex digits of the content's SHA-256, a stub for an unchanged re-read inside an earlier full read, a diff for an edit, a capped output with its elided line, a stub for a repeated listing or search with the same result. LLeMbas: services/context.py; CLI: session/results.ts.",
"cases": [
{
"steps": [
{
"op": "read",
"id": "#a001",
"path": "src/a.ts",
"content": "const a = 1\nexport { a }\n"
},
{
"op": "read",
"id": "#a002",
"path": "src/a.ts",
"content": "const a = 1\nexport { a }\n"
}
],
"expect": [
"[#a001 src/a.ts lines 1-2 of 2 sha 583e86b4]\n1: const a = 1\n2: export { a }",
"[#a002 src/a.ts lines 1-2 of 2 sha 583e86b4] Unchanged since #a001, which already shows lines 1-2."
]
},
{
"steps": [
{
"op": "read",
"id": "#b001",
"path": "notes.md",
"content": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n",
"limit": 5
},
{
"op": "read",
"id": "#b002",
"path": "notes.md",
"content": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n",
"offset": 2,
"limit": 3
},
{
"op": "read",
"id": "#b003",
"path": "notes.md",
"content": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n",
"offset": 4,
"limit": 5
},
{
"op": "read",
"id": "#b004",
"path": "notes.md",
"content": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n",
"offset": 5,
"limit": 2
}
],
"expect": [
"[#b001 notes.md lines 1-5 of 10 sha e71d970d]\n1: line 1\n2: line 2\n3: line 3\n4: line 4\n5: line 5\n\n(5 more lines — read again with offset 6)",
"[#b002 notes.md lines 2-4 of 10 sha e71d970d] Unchanged since #b001, which already shows lines 1-5.",
"[#b003 notes.md lines 4-8 of 10 sha e71d970d]\n4: line 4\n5: line 5\n6: line 6\n7: line 7\n8: line 8\n\n(2 more lines — read again with offset 9)",
"[#b004 notes.md lines 5-6 of 10 sha e71d970d] Unchanged since #b003, which already shows lines 4-8."
]
},
{
"steps": [
{
"op": "read",
"id": "#c001",
"path": "a.py",
"content": "x = 1\ny = 2\nz = 3\n"
},
{
"op": "edit",
"id": "#c002",
"path": "a.py",
"before": "x = 1\ny = 2\nz = 3\n",
"after": "x = 1\ny = 20\nz = 3\n"
},
{
"op": "read",
"id": "#c003",
"path": "a.py",
"content": "x = 1\ny = 20\nz = 3\n"
},
{
"op": "read",
"id": "#c004",
"path": "a.py",
"content": "x = 1\ny = 20\nz = 3\n"
}
],
"expect": [
"[#c001 a.py lines 1-3 of 3 sha af198c55]\n1: x = 1\n2: y = 2\n3: z = 3",
"[#c002 a.py sha a6a8e52b]\n@@ -1,3 +1,3 @@\n x = 1\n-y = 2\n+y = 20\n z = 3",
"[#c003 a.py lines 1-3 of 3 sha a6a8e52b]\n1: x = 1\n2: y = 20\n3: z = 3",
"[#c004 a.py lines 1-3 of 3 sha a6a8e52b] Unchanged since #c003, which already shows lines 1-3."
]
},
{
"steps": [
{
"op": "read",
"id": "#d001",
"path": "a.txt",
"content": "one\n"
},
{
"op": "read",
"id": "#d002",
"path": "a.txt",
"content": "one\ntwo\n"
},
{
"op": "read",
"id": "#d003",
"path": "b.txt",
"content": "one\n"
}
],
"expect": [
"[#d001 a.txt lines 1-1 of 1 sha 2c8b08da]\n1: one",
"[#d002 a.txt lines 1-2 of 2 sha c3f9c8c2]\n1: one\n2: two",
"[#d003 b.txt lines 1-1 of 1 sha 2c8b08da]\n1: one"
]
},
{
"limits": {
"max_lines": 6,
"max_chars": 40000,
"head_lines": 2,
"head_chars": 20000,
"tail_lines": 2,
"tail_chars": 10000
},
"steps": [
{
"op": "output",
"id": "#e001",
"tool": "bash",
"args": {
"command": "seq 10"
},
"text": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10"
},
{
"op": "recall",
"id": "#e002",
"target": "#e001",
"offset": 3,
"limit": 2
},
{
"op": "recall",
"id": "#e003",
"target": "e001"
}
],
"expect": [
"[#e001]\n1\n2\n[6 lines elided, full output #e001]\n9\n10",
"[#e002 recall #e001 lines 3-4 of 10]\n3\n4",
"[#e003 recall #e001 lines 1-10 of 10]\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10"
]
},
{
"steps": [
{
"op": "output",
"id": "#f001",
"tool": "list",
"args": {
"path": "src"
},
"text": "src/\n a.ts\n b.ts"
},
{
"op": "output",
"id": "#f002",
"tool": "list",
"args": {
"path": "src"
},
"text": "src/\n a.ts\n b.ts"
},
{
"op": "output",
"id": "#f003",
"tool": "list",
"args": {
"path": "src",
"depth": 1
},
"text": "src/\n a.ts\n b.ts"
},
{
"op": "output",
"id": "#f004",
"tool": "list",
"args": {
"path": "src"
},
"text": "src/\n a.ts\n b.ts\n c.ts"
},
{
"op": "output",
"id": "#f005",
"tool": "list",
"args": {
"path": "src"
},
"text": "src/\n a.ts\n b.ts\n c.ts"
}
],
"expect": [
"[#f001]\nsrc/\n a.ts\n b.ts",
"[#f002] Same result as #f001.",
"[#f003]\nsrc/\n a.ts\n b.ts",
"[#f004]\nsrc/\n a.ts\n b.ts\n c.ts",
"[#f005] Same result as #f004."
]
},
{
"steps": [
{
"op": "output",
"id": "#1001",
"tool": "bash",
"args": {
"command": "git status"
},
"text": "nothing to commit"
},
{
"op": "output",
"id": "#1002",
"tool": "bash",
"args": {
"command": "git status"
},
"text": "nothing to commit"
},
{
"op": "output",
"id": "#1003",
"tool": "grep",
"args": {
"pattern": "TODO"
},
"text": "a.ts:3: // TODO"
},
{
"op": "output",
"id": "#1004",
"tool": "grep",
"args": {
"pattern": "TODO"
},
"text": "a.ts:3: // TODO"
}
],
"expect": [
"[#1001]\nnothing to commit",
"[#1002]\nnothing to commit",
"[#1003]\na.ts:3: // TODO",
"[#1004] Same result as #1003."
]
},
{
"steps": [
{
"op": "read",
"id": "#2001",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "read",
"id": "#2002",
"path": "b.md",
"content": "beta\n"
},
{
"op": "read",
"id": "#2003",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "output",
"id": "#2004",
"tool": "glob",
"args": {
"pattern": "*.md"
},
"text": "a.md\nb.md"
},
{
"op": "compact"
},
{
"op": "read",
"id": "#2005",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "output",
"id": "#2006",
"tool": "glob",
"args": {
"pattern": "*.md"
},
"text": "a.md\nb.md"
},
{
"op": "recall",
"id": "#2007",
"target": "#2002"
}
],
"expect": [
"[#2001 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
"[#2002 b.md lines 1-1 of 1 sha f2c82dec]\n1: beta",
"[#2003 a.md lines 1-1 of 1 sha b6a98d9c] Unchanged since #2001, which already shows lines 1-1.",
"[#2004]\na.md\nb.md",
"## Files read before this summary\n\nRead a file again for its content; recall shows a result by its id.\n\n| Path | Lines | sha | Result |\n|---|---|---|---|\n| a.md | 1-1 of 1 | b6a98d9c | #2001 |\n| b.md | 1-1 of 1 | f2c82dec | #2002 |",
"[#2005 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
"[#2006]\na.md\nb.md",
"[#2007 recall #2002 lines 1-1 of 1]\n1: beta"
]
},
{
"steps": [
{
"op": "compact"
},
{
"op": "recall",
"id": "#3001",
"target": "#ffff"
}
],
"expect": [
"",
"[#3001] No stored result #ffff in this session."
]
},
{
"steps": [
{
"op": "write",
"id": "#4001",
"path": "new.md",
"after": "# Title\n\nText.\n"
},
{
"op": "read",
"id": "#4002",
"path": "empty.txt",
"content": ""
}
],
"expect": [
"[#4001 new.md sha fd9dba9b] Created, 3 lines.",
"[#4002 empty.txt empty sha e3b0c442]\n(empty file)"
]
},
{
"limits": {
"max_lines": 400,
"max_chars": 50,
"head_lines": 200,
"head_chars": 20,
"tail_lines": 100,
"tail_chars": 10
},
"steps": [
{
"op": "output",
"id": "#5001",
"tool": "bash",
"args": {
"command": "cat min.js"
},
"text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
{
"op": "output",
"id": "#5002",
"tool": "bash",
"args": {
"command": "cat log"
},
"text": "first line\nyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\nlast"
}
],
"expect": [
"[#5001]\nxxxxxxxxxxxxxxxxxxxx […cut]\n[0 lines elided, full output #5001]",
"[#5002]\nfirst line\n[1 lines elided, full output #5002]\nlast"
]
},
{
"steps": [
{
"op": "read",
"id": "#6001",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "output",
"id": "#6002",
"tool": "glob",
"args": {
"pattern": "*.md"
},
"text": "a.md"
},
{
"op": "reply"
},
{
"op": "read",
"id": "#6003",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "output",
"id": "#6004",
"tool": "glob",
"args": {
"pattern": "*.md"
},
"text": "a.md"
},
{
"op": "read",
"id": "#6005",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "read",
"id": "#6006",
"path": "b.md",
"content": "beta\n"
},
{
"op": "compact"
},
{
"op": "read",
"id": "#6007",
"path": "c.md",
"content": "gamma\n"
},
{
"op": "reply"
},
{
"op": "compact"
}
],
"expect": [
"[#6001 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
"[#6002]\na.md",
"",
"[#6003 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
"[#6004]\na.md",
"[#6005 a.md lines 1-1 of 1 sha b6a98d9c] Unchanged since #6003, which already shows lines 1-1.",
"[#6006 b.md lines 1-1 of 1 sha f2c82dec]\n1: beta",
"## Files read before this summary\n\nRead a file again for its content; recall shows a result by its id.\n\n| Path | Lines | sha | Result |\n|---|---|---|---|\n| a.md | 1-1 of 1 | b6a98d9c | #6003 |\n| b.md | 1-1 of 1 | f2c82dec | #6006 |",
"[#6007 c.md lines 1-1 of 1 sha ae9a6306]\n1: gamma",
"",
"## Files read before this summary\n\nRead a file again for its content; recall shows a result by its id.\n\n| Path | Lines | sha | Result |\n|---|---|---|---|\n| c.md | 1-1 of 1 | ae9a6306 | #6007 |"
]
}
]
}
+876
View File
@@ -0,0 +1,876 @@
{
"about": "The file system as one session sees it (filesystem.md): `tree` (paths; a directory ends with `/`, parents are implied), `mounts` (directories that are forge repositories), `grants` (path → level none/ro/stage/commit, covering everything below; the most specific wins; none by default), `floor` (path → the most a session may have there; the most specific applies), `cwd` (the start directory, default `/`), and one `op` on `path`: `level` (the session's level there, and whether it is a passage: at none with something granted below), `list` (the entries the session sees: directories and mounts with `/`, a mount ` [mount]`, an entry at ro in a writable directory ` [read-only]`; directories first, then by name), `read`, `write` (a file), `mkdir`, `rm`, `commit`. Answers `{ok: true}`, `{entries}`, `{level, passage}` or `{error}` with the exact text, naming the resolved absolute path. A path the session cannot see is answered by what the model already knows: by its nearest ancestor that is visible (No such file or directory) or a passage (Access denied) — so a hidden entry and a missing one read the same; creating a name taken by an entry at none is denied. LLeMbas: services/fs.py (the view) with security/access.py (levels and grants); CLI: not run until it has a view of its own (an agent's disk is one level for the whole working directory).",
"cases": [
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "level",
"path": "/Dir1",
"expect": {
"level": "none",
"passage": true
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "level",
"path": "/Dir2",
"expect": {
"level": "none",
"passage": false
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "level",
"path": "/Dir1/Dir3/a.md",
"expect": {
"level": "stage",
"passage": false
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "list",
"path": "/Dir1",
"expect": {
"error": "Access denied: /Dir1 is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "list",
"path": "/",
"expect": {
"error": "Access denied: / is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Dir1/FILE1.txt",
"expect": {
"error": "Access denied: /Dir1/FILE1.txt is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "/Dir1/new.txt",
"expect": {
"error": "Access denied: /Dir1/new.txt is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "mkdir",
"path": "/Dir1/x",
"expect": {
"error": "Access denied: /Dir1/x is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Dir2/b.md",
"expect": {
"error": "Access denied: /Dir2/b.md is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Dir2",
"expect": {
"error": "Access denied: /Dir2 is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Nope.md",
"expect": {
"error": "Access denied: /Nope.md is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Dir1/Dir3/missing.md",
"expect": {
"error": "No such file or directory: /Dir1/Dir3/missing.md"
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "/Dir2/x.md",
"expect": {
"error": "Access denied: /Dir2/x.md is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "list",
"path": "/Dir1/Dir3",
"expect": {
"entries": [
"a.md"
]
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/AGENTS.md",
"expect": {
"ok": true
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "/AGENTS.md",
"expect": {
"error": "Read-only: /AGENTS.md can be read in this session but not changed."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "/Dir1/Dir3/new.md",
"expect": {
"ok": true
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "new.md",
"cwd": "/Dir1/Dir3",
"expect": {
"ok": true
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "../FILE1.txt",
"cwd": "/Dir1/Dir3",
"expect": {
"error": "Access denied: /Dir1/FILE1.txt is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "list",
"path": "/Dir1/Dir3/../../Dir1//Dir3/",
"expect": {
"entries": [
"a.md"
]
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "list",
"path": "/",
"expect": {
"entries": [
"Notes/",
"Ref/ [read-only]"
]
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "list",
"path": "/Notes",
"expect": {
"entries": [
"a.md"
]
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "read",
"path": "/Notes/Private/p.md",
"expect": {
"error": "No such file or directory: /Notes/Private/p.md"
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "mkdir",
"path": "/Notes/Private",
"expect": {
"error": "Access denied: /Notes/Private is not open to this session."
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "write",
"path": "/Notes/Private/x.md",
"expect": {
"error": "Access denied: /Notes/Private/x.md is not open to this session."
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "rm",
"path": "/Notes/Private",
"expect": {
"error": "No such file or directory: /Notes/Private"
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "rm",
"path": "/Notes/gone.md",
"expect": {
"error": "No such file or directory: /Notes/gone.md"
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "write",
"path": "/Ref/r.md",
"expect": {
"error": "Read-only: /Ref/r.md can be read in this session but not changed."
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "write",
"path": "/Ref/new.md",
"expect": {
"error": "Read-only: /Ref/new.md can be read in this session but not changed."
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "rm",
"path": "/Notes/a.md",
"expect": {
"ok": true
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "commit"
},
"floor": {
"/Ref": "ro"
},
"op": "level",
"path": "/Ref/r.md",
"expect": {
"level": "ro",
"passage": false
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "commit"
},
"floor": {
"/Ref": "ro"
},
"op": "write",
"path": "/Ref/r.md",
"expect": {
"error": "Read-only: /Ref/r.md can be read in this session but not changed."
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit",
"/repo": "stage"
},
"op": "list",
"path": "/",
"expect": {
"entries": [
"home/",
"repo/ [mount]"
]
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit",
"/repo": "stage"
},
"op": "commit",
"path": "/repo/src/x.ts",
"expect": {
"error": "Not committed: this session may change /repo/src/x.ts but not commit it; the change stays staged."
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "commit",
"path": "/home/n.md",
"expect": {
"error": "Nothing to commit: /home/n.md is not in a repository."
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/repo": "commit"
},
"op": "commit",
"path": "/repo/README.md",
"expect": {
"ok": true
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/repo": "ro"
},
"op": "commit",
"path": "/repo/README.md",
"expect": {
"error": "Read-only: /repo/README.md can be read in this session but not changed."
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "write",
"path": "/repo/src",
"expect": {
"error": "Is a directory: /repo/src"
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "write",
"path": "/home/n.md/x",
"expect": {
"error": "Not a directory: /home/n.md"
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "mkdir",
"path": "/home/n.md",
"expect": {
"error": "Already exists: /home/n.md"
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "mkdir",
"path": "/home",
"expect": {
"ok": true
}
}
]
}
+129
View File
@@ -0,0 +1,129 @@
{
"about": "The instruction files a session starts with (filesystem.md, Instruction files, stacked): `files` that exist, the `boundary` and the `start` directory (outside the boundary: read as the boundary), and `grants` as in fs_access (absent: everything readable). From the boundary down to the start directory, outermost first, each directory gives the first of AGENTS.md, CLAUDE.md that is a file the session can read — never both; a file at none (in a passage, or taken away) is not loaded and the walk goes on below it. The answer is the loaded paths, in order. The CLI's global AGENTS.md, before the chain, and its configured `instructions`, after it, are not part of these cases. LLeMbas: services/workspace.py chain; CLI: prompt/assemble.ts instructionChain.",
"cases": [
{
"files": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
],
"boundary": "/",
"start": "/sec/repo",
"expect": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md"
]
},
{
"files": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
],
"boundary": "/",
"start": "/sec/repo/src",
"expect": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
]
},
{
"files": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
],
"boundary": "/sec",
"start": "/sec/repo",
"expect": [
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md"
]
},
{
"files": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
],
"boundary": "/sec",
"start": "/other",
"expect": [
"/sec/AGENTS.md"
]
},
{
"files": [
"/x/AGENTS.md",
"/x/CLAUDE.md"
],
"boundary": "/",
"start": "/x",
"expect": [
"/x/AGENTS.md"
]
},
{
"files": [
"/AGENTS.md",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/AGENTS.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"boundary": "/",
"start": "/Dir1/Dir3",
"expect": [
"/AGENTS.md",
"/Dir1/Dir3/AGENTS.md"
]
},
{
"files": [
"/x/AGENTS.md",
"/x/CLAUDE.md"
],
"grants": {
"/": "ro",
"/x/AGENTS.md": "none"
},
"boundary": "/",
"start": "/x",
"expect": [
"/x/CLAUDE.md"
]
},
{
"files": [
"/AGENTS.md",
"/a/AGENTS.md",
"/a/b/AGENTS.md"
],
"grants": {
"/a/b": "ro"
},
"boundary": "/",
"start": "/a/b",
"expect": [
"/a/b/AGENTS.md"
]
},
{
"files": [
"/README.md"
],
"boundary": "/",
"start": "/",
"expect": []
}
]
}
+535
View File
@@ -0,0 +1,535 @@
{
"about": "The prefix (context.md §10, spec 3.1.0): one session's requests, in order — which system prompt each sends, which tools it offers, and the note on a user message. `start` is the session as it begins: `date`, `mode`, and what the prompt is made from that may change (`plan` as rendered, `memory`, `personality`, `files` — instruction texts by path or heading — `mcp` — instructions by server; each \"\" or {} for none), `could` (the requirements the session could ever meet: `plan` for any session that may enter plan mode, `display`, `page`, `forge`) and `met` (those met now besides plan, which is met in plan mode; default: all it could). `tools`: the names a case looks at; a tool's `requires` is the spec's. Steps: `user` (a user message: the request that carries it), `tool` (a tool step's request: no user message), `set` (something changes — any of the start's fields, or `met`; `by: model` when the model's own tool did it, whose result told the model: `plan` from plan_write, `mode` from plan_submit's approval, `memory` and `files` it changed itself; otherwise the person or the clock did), `call` (a call of `tool` now), `compact` (the summary is asked for: `{{transcript}}` and `{{previous_summary}}` empty — an earlier summary is already the first message), `wrapup` (the request after a step limit or a budget: the same prompt and tools, the wrap-up message as its last user message, no note; a call in its reply is not run) and `model` (the session moves to another model). A request answers `{system, tools, note}`: `system` numbers the system prompts the session has made, from 1, the same number being the same bytes; `tools` the case's tools it offers; `note` the note after the person's words, or null. `call` answers the tool's `unavailable` text, or null where it runs; `compact` answers the request's prompt, its tools and `ask`, the user message that follows every message as it was; `wrapup` answers like a request; `set` and `model` answer null; a value that became empty reads `context.note.empty`. `plan_dir` fills modes/plan.md. LLeMbas: services/context.py; CLI: session/prefix.ts, session/engine.ts.",
"cases": [
{
"about": "A date that changes during a session is a note on the next user message; the system prompt and the tools stay the same bytes.",
"start": {
"date": "2026-11-04",
"mode": "manual",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "tool"
},
{
"op": "user"
},
{
"op": "set",
"date": "2026-11-05"
},
{
"op": "tool"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-04.\n</session-note>"
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
null,
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
}
]
},
{
"about": "plan_submit is offered in every mode and answers its unavailable text outside plan mode; a mode the person chose is a note with the mode's text, one the approval set is not.",
"start": {
"date": "2026-11-05",
"mode": "manual",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_write",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "call",
"tool": "plan_submit"
},
{
"op": "set",
"mode": "plan"
},
{
"op": "user"
},
{
"op": "call",
"tool": "plan_submit"
},
{
"op": "set",
"mode": "edit",
"by": "model"
},
{
"op": "tool"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
"Not in plan mode: plan_submit is how a plan made in plan mode is put to the person for approval. Carry on with the work; keep the plan current with plan_update.",
null,
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": "<session-note>\nThe permission mode is now plan:\nPermission mode: plan. You are planning, not implementing. Read, search and investigate as much as you need, but change nothing: no file but a plan under .agent/plans, no command that is not already allowed, nothing saved to the library.\n\nWrite the plan with plan_write, so that someone else could follow it: findings with file:line references, what will be true when it is done (objectives), and the work as phases of tasks in order, with the risks and how to verify it as the last phase. Ask with ask_user if a decision is the user's. When the plan is written, call plan_submit — that is how the user approves it, and after approval you carry on and implement it, keeping the plan current with plan_update.\n</session-note>"
},
null,
null,
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": null
}
]
},
{
"about": "The compaction request is the same prompt and tools, every message, then the compaction text with no transcript; after it the prompt is made again and the next user message says the date again.",
"start": {
"date": "2026-11-05",
"mode": "edit",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "tool"
},
{
"op": "compact"
},
{
"op": "user"
},
{
"op": "compact"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"ask": "Summarise the conversation below so it can be carried forward after the earlier turns are dropped from your context. This is a working record, not a report for a reader.\n\nKeep, under these headings and in this order:\n\n## What we are doing\nThe goal, and where we have got to.\n\n## Decisions\nAnything settled, and why. A decision without its reason gets argued again.\n\n## Facts established\nNames, numbers, versions, file paths, commands, URLs and identifiers, copied exactly. Do not round them, paraphrase them or reconstruct one from memory — if it is not in the transcript, leave it out.\n\n## Files touched\nEach file read or changed that still matters, and what was done to it.\n\n## Open threads\nWhat is unfinished, and what was about to happen next.\n\nLeave out pleasantries, retracted ideas and anything already superseded. Do not answer the conversation: you are recording it. Write in the language of the conversation, and stay under 600 words."
},
{
"system": 2,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
{
"system": 2,
"tools": [
"read",
"plan_submit"
],
"ask": "Summarise the conversation below so it can be carried forward after the earlier turns are dropped from your context. This is a working record, not a report for a reader.\n\nKeep, under these headings and in this order:\n\n## What we are doing\nThe goal, and where we have got to.\n\n## Decisions\nAnything settled, and why. A decision without its reason gets argued again.\n\n## Facts established\nNames, numbers, versions, file paths, commands, URLs and identifiers, copied exactly. Do not round them, paraphrase them or reconstruct one from memory — if it is not in the transcript, leave it out.\n\n## Files touched\nEach file read or changed that still matters, and what was done to it.\n\n## Open threads\nWhat is unfinished, and what was about to happen next.\n\nLeave out pleasantries, retracted ideas and anything already superseded. Do not answer the conversation: you are recording it. Write in the language of the conversation, and stay under 600 words."
}
]
},
{
"about": "What the model changed itself is not said again; what the person changed is, once, in loop.json's order; an MCP server that connected later is said by its own instructions.",
"start": {
"date": "2026-11-05",
"mode": "manual",
"plan": "",
"memory": "Prefers tabs.",
"personality": "",
"files": {
"/p/AGENTS.md": "Build with make."
},
"mcp": {
"docs": "Search the docs first."
},
"could": [
"plan"
]
},
"tools": [
"read"
],
"steps": [
{
"op": "user"
},
{
"op": "set",
"by": "model",
"plan": "# Fix\n## p1 P (pending)\n[t1] [ ] a",
"memory": "Prefers tabs.\n§\nUses fish.",
"files": {
"/p/AGENTS.md": "Build with make; test with make test."
}
},
{
"op": "tool"
},
{
"op": "user"
},
{
"op": "set",
"plan": "",
"memory": "Uses fish.",
"personality": "Be brief.",
"files": {
"/p/AGENTS.md": "Build with make; test with make test.",
"/p/src/AGENTS.md": "Strict types."
},
"mcp": {
"docs": "Search the docs first.",
"tickets": "Name the ticket in every commit."
}
},
{
"op": "user"
},
{
"op": "set",
"files": {
"/p/AGENTS.md": "Build with make; test with make test."
}
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
null,
{
"system": 1,
"tools": [
"read"
],
"note": null
},
{
"system": 1,
"tools": [
"read"
],
"note": null
},
null,
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nThe person changed the plan; it is now:\n(none)\n\nThe person changed what is remembered; it is now:\nUses fish.\n\n/p/src/AGENTS.md changed; it now reads:\nStrict types.\n\nThe person chose a different personality:\nBe brief.\n\nInstructions from an MCP server that connected since the session began:\n<server name=\"tickets\">\nName the ticket in every commit.\n</server>\n</session-note>"
},
null,
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\n/p/src/AGENTS.md changed; it now reads:\n(none)\n</session-note>"
}
]
},
{
"about": "A move to another model makes the prompt again; the date said in the conversation still holds.",
"start": {
"date": "2026-11-05",
"mode": "auto",
"could": [
"plan"
]
},
"tools": [
"read"
],
"steps": [
{
"op": "user"
},
{
"op": "model"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
null,
{
"system": 2,
"tools": [
"read"
],
"note": null
}
]
},
{
"about": "A display the session could have is offered from the start and answers unavailable while there is none; one it never could have leaves show out.",
"start": {
"date": "2026-11-05",
"mode": "manual",
"could": [
"plan",
"display"
],
"met": []
},
"tools": [
"read",
"show"
],
"steps": [
{
"op": "user"
},
{
"op": "call",
"tool": "show"
},
{
"op": "set",
"met": [
"display"
]
},
{
"op": "call",
"tool": "show"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"show"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
"Nothing can be shown now: this session has no screen to show a file on. Give the user the path instead.",
null,
null
]
},
{
"start": {
"date": "2026-11-05",
"mode": "manual",
"could": [
"plan"
]
},
"tools": [
"read",
"show"
],
"steps": [
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
}
]
},
{
"about": "The wrap-up keeps the session's tool list and prompt, carries no note, and the next user message carries what changed meanwhile.",
"start": {
"date": "2026-11-05",
"mode": "edit",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "tool"
},
{
"op": "set",
"date": "2026-11-06"
},
{
"op": "wrapup"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
null,
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-06.\n</session-note>"
}
]
}
]
}
+236
View File
@@ -0,0 +1,236 @@
# Context hygiene
Spec 3.1.0. What a session does to keep tool results from filling the model's context, and (§10, from
3.1.0) what it keeps still so the model's server can reuse what it has already read — the same in
a chat and in an agent session, in both projects. The texts and numbers are `loop.json` `context`;
the cases are `conformance/context.json`; what the model is told is `prompts/blocks/context.md`.
## The one rule
**Every measure acts when a result is inserted. Nothing already sent to the model is ever
rewritten.** Changing earlier context invalidates the prompt cache from that point on: a local
server processes everything after it again, and a cloud API charges full input price for it again —
on a long context that costs more than the measure saves. So no earlier result is ever shortened,
replaced by a stub or removed afterwards. The one deliberate break is compaction, which already
replaces the history with a summary.
## 1. Every result has an id
Every tool result is given an id when it is inserted: `#` and the result's number in the session —
counting from 1, in lowercase hex, at least `ids.digits` (4) digits — so `#0001`, `#0002`, …,
`#000a`, …, `#10000`. Unique in the session and never reused — not after a compaction either. It is
the first thing in the result, in brackets: `[#0001]`, then a newline and the result.
A read's bracket says more: the path **as the call gave it**, the lines shown, the file's length in
lines and the first `sha_digits` (8) hex digits of the SHA-256 of the file's content —
```
[#0012 src/app.ts lines 1-200 of 340 sha 1c9e04ab]
1: import …
```
— and an empty file `[#0013 notes.txt empty sha e3b0c442]`. The full hash is kept by the session;
only it decides whether a file is unchanged.
**Paths in a bracket are the call's.** A read's bracket, an edit's (§5) and the compaction table
(§7) name the path as the call gave it — what the model wrote, so it recognises it. What identifies
the file — for a stub, for the table's "newest read of each path", for whether a file was read before
it is changed — is its absolute, resolved path (`filesystem.md`, *Paths*). Every other answer (an
error, a refusal, a search's results) names the absolute path.
**Every result is stored with the session, in full** — before any cap, and for a stub the text it
stands for — so `recall` can show any of them for as long as the session exists.
## 2. An unchanged re-read is a stub
**A stub names only a result that is in the request being built** — one whose text is among the
tool results this request sends to the model. That set, not anything the session stored, is the
dedupe table. Where a request does not carry earlier results — a chat on the server sends no earlier
reply's tool results with the next reply; after an undo, a rewind or a compaction they are gone — a
read is answered in full, because what a stub would point at is not in front of the model. It
holds for §2 and §6 alike.
A read is answered with a stub when the file's content hash equals that of an **earlier full read
of the same file still in the dedupe table**, and the lines asked for lie **wholly inside** that
read's lines:
```
[#0019 src/app.ts lines 50-80 of 340 sha 1c9e04ab] Unchanged since #0012, which already shows lines 1-200.
```
The bracket is the new read's; the sentence names the earlier result and its lines. A stub points
at a full result, never at another stub. A file that changed — by this session's own edit or from
outside — has another hash and is read in full; a range reaching past the earlier read is read in
full.
## 3. Large output is capped on the way in
A result over `cap.max_lines` (400) lines or `cap.max_chars` (40,000) characters keeps its head —
as many whole lines as fit `head_lines` (200) and `head_chars` (20,000) — and its tail — likewise,
`tail_lines` (100) and `tail_chars` (10,000) — with one line between:
```
[1234 lines elided, full output #0020]
```
A first line longer than `head_chars` is cut to it and ends ` […cut]`; the elided line follows it
whatever it counts. Reads and `recall` are not capped this way: each has its own window (2000 lines
from an offset).
## 4. `recall`
`recall(id, offset?, limit?)` shows a stored result again, in full, up to 2000 lines from `offset`:
```
[#0021 recall #0020 lines 1-2000 of 3400]
…
```
An id the session never gave: `[#0021] No stored result #ffff in this session.` The `#` may be left
out of the argument. A recall is not capped, is not deduplicated, and is itself stored.
## 5. Edits answer with the diff
`edit`, `multiedit`, `apply_patch` and `write` over an existing file answer with the unified diff
of the change (three lines of context, hunks only — the path is in the bracket) under a bracket
naming the path and the new content's hash:
```
[#0022 src/app.ts sha 7d0a91c3]
@@ -12,3 +12,3 @@
```
`write` of a new file answers `[#0023 docs/new.md sha 52b6e3a0] Created, 14 lines.` — the model
wrote the content and has it. A deleted file is `[#0024 old.md deleted]`. An `apply_patch` over
several files answers the id alone, then each file in its own bracket with its change, in the
patch's order:
```
[#0025]
[src/a.ts sha 7d0a91c3]
@@ -1,2 +1,2 @@
[docs/new.md sha 52b6e3a0] Created, 14 lines.
[old.md deleted]
```
A note the tool adds (a skill file written outside skill_manage) follows the diff. The diff is
capped like any output, the first line kept.
## 6. The same search or listing twice is a stub
For `dedupe.by_result` — `list`, `glob`, `grep`, `search` — a call with the same arguments (compared
as JSON with sorted keys) as an earlier one in the dedupe table, **whose result is the same text**,
is answered `[#0027] Same result as #0026.` The call is run again and compared, so a change from
anywhere shows up as a full result. Other tools are never deduplicated: a command's output twice is
two results.
## 7. Compaction
Compaction is the one deliberate break of the cache, and it already exists. **From 3.1.0 the
summary is asked for as the next turn of the same conversation**: the same system prompt, the same
tools, every message as it was, and then one more user message — `prompts/tasks/compact.md`, whose
`{{transcript}}` and `{{previous_summary}}` are empty in this form, since the model has the
conversation, an earlier summary included, itself. So the request
that makes the summary costs only that message; a separate request with a pasted transcript was a
full re-read of everything, and a transcript capped to fit it lost the session's middle. A tool
call in the summary's reply is not run; its text before the call is the summary, and a reply with
none fails the compaction as an empty one does. The summary request is never shown as a turn of the
session, and it is not stored as one. In 3.0.0 two things came with compaction, and still do:
- After the summary the harness adds — itself, not the model — a table of the **reads the summary
covers**: the full reads made since the previous compaction (or since the session began) in turns
that are still part of the history being summarised — whether or not their results are still in
the request. A turn taken back (an undo, a rewind, a reply regenerated or deleted) takes its reads
with it. Of those, the newest full read of each file (by its absolute path), newest last, at most
`compaction.rows` (50); a stub is not a read here. A session that resends every result (the CLI)
finds exactly these in its dedupe table; a chat, which does not, finds them in what it stored for
the turns being summarised:
```
## Files read before this summary
Read a file again for its content; recall shows a result by its id.
| Path | Lines | sha | Result |
|---|---|---|---|
| src/app.ts | 1-200 of 340 | 1c9e04ab | #0012 |
```
With no reads there is no table.
- **The dedupe table is emptied**, so no stub ever points at content that is no longer in the
context. A read after compaction is a full read; a `recall` of any earlier id still works.
## 10. The prefix: what the model has already read
A model's server keeps what it has read of a request and reuses it for the next one only as far as
the two are the same from the first byte. The system prompt and the tool list come first, so **a
change to either is a full re-read** of everything after them — on a long session minutes of work
on a local server, and the full input price again on a cloud API. Measured (2026-11-05, a local
model): a date that changed at midnight sent a 16,912-token session from 99 % cached to 0 %,
139 seconds. So, from 3.1.0:
1. **The system prompt is made once per session and sent unchanged.** It is built when the session
starts, kept with the session, and every request sends those exact bytes. It is built again only
where everything is read again anyway: after a compaction (§7), or when the session moves to
another model. A session that is resumed sends what it kept.
2. **Nothing in it is a clock.** No date or time. The date goes in a note (rule 3): the first user
message of a session carries it, and so does the next one after the date has changed.
3. **What changes during a session goes at the end, as a note** on the next user message, never
into earlier text: the permission mode (`modes/<mode>.md` as the note's body when it changes,
and its first line is in the prompt only as it was at the start), the plan when the person changed
it (the model's own `plan_write` and `plan_update` already answer with it), a memory or
instruction file the person edited, a personality chosen, an MCP server's instructions that
arrived later, the date. The note is a block after the person's words:
```
<session-note>
The permission mode is now plan: …
</session-note>
```
(`loop.json` `context.note`.) Each change is one block of the note, the blocks separated by a
blank line, in the order `context.note` lists them; a value that became empty reads
`context.note.empty`; the date is the person's own (their zone, else the machine's). A note is
part of that message from then on, like anything sent. A request with no user message (a tool
step), and words the person adds to a turn already running, carry no note; it waits for the next
message that starts a turn. A change the model made itself (its own `plan_write`, `memory`,
`settings`, a file it wrote) needs no note: its tool result already said it.
4. **The tool list is the same for the whole session.** A tool whose `requires` can come and go
during a session — `page`, `display`, `forge`, and plan mode for `plan_submit` — is offered from
the start whenever the session could ever meet it, and a call made while it is not met answers
the tool's `unavailable` text. Only what is fixed for the session may leave a tool out: the
session kind (`sessions`), the model (`vision`), a subagent's narrowing, what the project's
configuration or the person switched off before the session began. The tools stay listed in
the wrap-up request too (`loop.json` `wrap_up`): its message says they are withdrawn, and a call
in that reply is not run. A tool withdrawn after its final refusal stays in the list and answers
that refusal. **The one exception is an MCP server**: a session waits up to `mcp_wait_ms` for
the servers it is configured with before its first request, so their tools are in the first
list; a server that connects later adds its tools when it connects — one re-read, once — and its
instructions come as a note.
5. **Nothing already sent is rewritten** — the one rule above, for results; old tool output is
never pruned in place.
6. **Every request's cache use is recorded** where the provider reports it (cached input tokens),
beside the request's other usage, so a slow prompt has a cause on record.
What a server does with a matching prefix is its own business; a harness that keeps these rules has
done what it can.
## 8. Earlier reasoning
Left to the model's own chat template: the harness sends the history as it does, and neither strips
reasoning a template keeps nor adds what it drops.
## 9. What the model is told
`prompts/blocks/context.md`, in every session that has `recall`.
## Not in 3.1.0
Eliding old content in batches behind a threshold, and measuring quality and tokens with and without
these measures, are left for a later spec. Every measure above ships with its unit and conformance
cases.
## Over the link
A device keeps its sessions' stored results itself; `recall` in an agent session reads the device's
store. The ids are in the result text, so a client showing the transcript shows them as they are.
+264
View File
@@ -0,0 +1,264 @@
# The file system a session works in
Spec 3.0.0. **Two worlds, one harness.** A **chat** (a conversation on the server) works in the
server's file system: a tree kept by the instance, which may hold repositories mounted from a forge.
An **agent** session (LLeMbas CLI, Desktop's agent, or an agent chat the web UI runs on a linked
device) works in its working directory on its own machine. Neither sees the other's files. What
the two share is this contract: how paths read, what kinds of node there are, how access is said,
what an error says, how a listing and a search look, and how instruction files are collected. The
tools are the same names and schemas on both sides (`tools/*.json`, `sessions`); where a tool exists
on one side only, it says so there.
The conformance areas are `fs_access` (the view and its errors) and `instructions` (the instruction
chain). An agent session's local disk is the special case of one node kind less (no mounts) and
one access level for the whole working directory, decided by the CLI's permission rules and modes
(`permission/`) rather than by the table below.
## Paths
- **Absolute** paths begin with `/`, the root of the tree: the organisation's root on the server,
the file system's root on a device.
- **Relative** paths resolve against the session's **start directory** (its working directory).
- `.` and `..` are resolved by their text, before anything is looked up; `..` above `/` stays at
`/`. Repeated `/` count as one, and a trailing `/` changes nothing. On the server there are no
links: a path is what it says. On a device, a link is followed, and where the CLI's rules look at
a path they look at where it really is (an `AGENTS.md` committed as a link to a file outside the
project is not read).
- Every answer names a path in its **absolute, resolved** form, whatever the call gave — an
error, a refusal, a search's results, a tool's own message. **The one exception is a result's
bracket** (`context.md`): a read's header, an edit's bracket and the compaction table name the
path as the call gave it, so the model recognises what it wrote. What identifies a file, there
too, is its absolute path. A listing names entries by their names under the directory listed
(*A listing*).
- Names are case-sensitive, `/`-separated, and never empty, `.` or `..`.
## Node kinds
| Kind | What |
|---|---|
| `dir` | a directory of the tree |
| `file` | a file: bytes, with a content hash (SHA-256) |
| `mount` | a directory that is a repository on a forge, at one branch. Below it, every node is a `dir` or a `file` of that repository. Server only |
A session has a **boundary** (what it may see at all: the root, a directory, one repository) and a
**start directory** inside it. On a device the boundary is the project root (the directory the
CLI's trust is given to) and the start directory is where the session started.
## Access levels
Each path is at one of four levels for a session:
| Level | Name | Read | Change | Commit |
|---|---|---|---|---|
| `none` | no access | no | no | no |
| `ro` | read-only | yes | no | no |
| `stage` | read/write, stage | yes | yes | no |
| `commit` | read/write, commit | yes | yes | yes |
- **Inside a mount**, `stage` means a change goes into the session's **working copy** (kept by the
instance) and stops there; `commit` means the session may also send it to the forge (`commit`,
`branch`, `pr`, `merge`, `tag`, `release`). Outside mounts there is nothing to commit, and the
two write levels mean the same: write.
- **A grant** names a path and a level, and covers everything below the path. **The most specific
grant wins**: `/` at `ro` and `/Notes` at `stage` make `/Notes/a.md` writable and `/b.md`
read-only; `/Notes/Private` at `none` takes that directory away again.
- **A floor** is a maximum level per path, set where the session cannot widen it (the data group's).
The most specific floor entry applies; the level a session has is its grant, lowered to the floor.
- A path with no grant on it or above it is at `none`. **Only what is at `ro` or above exists for
the model.** On a mount, the forge decides first: a level is never above what the user's own
forge account may do there.
- The levels a session has are told to the model (`prompts/blocks/workspace.md`, `{{view}}`): every
granted path with its level, so it knows the full paths it may use.
### A granted path under a path that is not
The tree `/AGENTS.md`, `/Dir1/`, `/Dir1/FILE1.txt`, `/Dir1/Dir3/`, `/Dir2/`, with `/AGENTS.md` and
`/Dir1/Dir3/` granted and nothing else:
- The model sees **`/AGENTS.md` and `/Dir1/Dir3/`**, each **by its full path**, and everything below
`/Dir1/Dir3/`.
- `/` and `/Dir1` are **passages**: at `none`, with something granted below. A passage is denied for
reading and writing alike: `list /Dir1`, `read /Dir1/FILE1.txt`, `write /Dir1/new.txt` and
`mkdir /Dir1/x` all answer `Access denied: … is not open to this session.`
- A listing never reveals a sibling: nothing lists `/Dir1`, and a listing of a granted directory
shows only its entries at `ro` or above.
- **A path the session cannot see is answered by what the model already knows**: by its nearest
ancestor that is either visible or a passage. Under a visible directory it is
`No such file or directory`; under a passage, `Access denied`. So `/Dir2/b.md` (hidden) and
`/Nope.md` (missing) get the same answer here — both under the passage `/` — and a hidden entry
under a granted directory reads exactly as a missing one.
- A search (`glob`, `grep`, `search`) rooted at a granted directory finds what is granted below it,
however it is reached; one rooted at a passage is denied like a listing.
- **An instruction file in a passage is not loaded** (below).
**One thing cannot be hidden**, and the spec accepts it: under a directory the session may write, a
name taken by an entry at `none` cannot be created. Making it answers `Access denied` for that path,
which says the name is taken and nothing else — not its kind, its size or what is in it.
## What each operation needs
| Operation | Tools | Needs |
|---|---|---|
| look | `list`, `read`, `glob`, `grep`, `search`, `show`, `python`'s `files`, `skill_view`, `skills_list` | `ro` |
| change | `write`, `edit`, `mkdir`, `mv` (the source and the target's directory), `rm`, `python`'s `save`, `skill_manage` | `stage` |
| commit | `commit`, `branch`, `pr`, `merge`, `tag`, `release` | `commit`, inside a mount |
A new path takes the level of the most specific grant above it. `mv` between a mount and the rest
of the tree, or between two mounts, is refused; `rm` of a mount itself is refused (a mount is
removed by whoever mounted it, not by a session). `rm` on the server moves to the **trash**,
restorable from the file explorer; inside a mount it stages the deletion.
## Errors
A tool's error is its result (`loop.json` `parallel`). The texts, exactly, with `{path}` the
resolved absolute path:
| Case | Text |
|---|---|
| not visible (missing, or at `none`), and its nearest visible-or-passage ancestor is visible | `No such file or directory: {path}` |
| a passage; not visible with a passage as that ancestor; a name taken by an entry at `none` | `Access denied: {path} is not open to this session.` |
| a change at `ro` | `Read-only: {path} can be read in this session but not changed.` |
| a commit at `stage` | `Not committed: this session may change {path} but not commit it; the change stays staged.` |
| a commit outside every mount | `Nothing to commit: {path} is not in a repository.` |
| a directory where a file was meant | `Is a directory: {path}` |
| a file where a directory was meant | `Not a directory: {path}` |
| `mv`'s target, or a file `mkdir` was asked to make | `Already exists: {path}` |
| a tool whose `requires` is not met at the time of the call | the tool's `unavailable` text |
A missing file is `No such file or directory` for **every** tool that needs it to exist — `read`,
`edit`, `show`, `list`, `glob`, `grep`, `python`'s `files` — with no hint after it: in a chat a
hidden file must read exactly as a missing one, and a hint ("use write to create it") would say
which. The tool's description says what it needs.
`write` and `edit` refuse to change an existing file the session has not seen, or one that changed
since it last saw it (by reading it, or by its own change). The texts, exactly:
| Case | Text |
|---|---|
| the file exists and this session has not read it | `{path} exists and has not been read in this session. Read it first.` |
| it changed since this session last read or changed it | `{path} changed since you last read it. Read it again before changing it.` |
How a change is noticed is each side's: the content hash on the server, the modification time on a
device.
A device's own errors (a file too large, a binary file, a protected path) are the CLI's and stay as
they are; the ones above it says the same way.
## A listing
`list` and a directory given to `read`: one entry per line, directories (and mounts) with a trailing
`/`, then — in a chat — a mount marked ` [mount]` and an entry the session can read but not change,
in a directory it can, marked ` [read-only]`. Directories first, then by name in code-point order
(`B` before `a`). An empty directory is `(empty directory)` from `read`, `(empty)` from `list`.
`list` with a `depth` (default 2, at most 6) is a tree: the entries of the directory listed, and
under each directory the entries of that directory, each level indented by two more spaces, down to
`depth` levels in all (depth 1 is the directory's own entries). The order and the marks are a single
listing's, at every level; a directory's entries come right after it. At most 400 lines, then
`(more entries — list a directory further down)` on its own line. A device leaves out ignored and
build directories (`.git`, `node_modules`, …, and what `.gitignore` says) and shows only
directories that hold a file it would list.
```
docs/
guide/
index.md
repo/ [mount]
src/
README.md [read-only]
notes.md
```
## A search
`search(query, path?, limit?)` ranks passages by meaning (embeddings) and by words (full-text) fused
by rank, then keeps only what the session's view allows — **after** scoring, so a hidden file
changes neither the results nor how many there are. A result is the passage's path and line range,
then the passage:
```
/Notes/setup.md:12-30
<the passage>
```
`grep` is the exact counterpart (text or a pattern), over the same view.
### Indexing, by tokens
What `search` reads is built when a file is written (a mount: its default branch, and again for the
files a new commit changes). The index is keyed by **content hash**, so a file in many places, or
unchanged across commits and branches, is embedded once; a rename or a move costs nothing.
Text is cut into pieces **by tokens, not by characters**: an embedder refuses input past its
context (512 tokens is common), and dense text — another language than English, code, hashes —
holds far more tokens per character than prose. A piece is at most **480 tokens** (`size`; room for
the embedder's own markers under 512) and repeats the last **64 tokens** (`overlap`) of the one
before it. The cut rules are the character splitter's (`conformance/chunk.json`) measured in tokens:
the window is the longest stretch of at most `size` tokens; the cut is just after a paragraph
break, else a line break, else a sentence's `. `, looked for in the window's last third (by
characters), else at the window's end; the next piece begins at the start of the token `overlap`
tokens before the cut. Pieces under 40 characters are dropped; a text that fits is one piece.
Tokens are counted with the embedder's own tokenizer where the server says how (llama.cpp's
`/tokenize`, for one). Where it does not, a count of `ceil(UTF-8 bytes / 2)` stands in — an
estimate, not a bound: it has not been measured against a real tokenizer. **A piece the embedder
still refuses for its length is split in two at the best cut and both are embedded; nothing is
dropped.**
`conformance/chunk_tokens.json` fixes the rules with a stand-in tokenizer (a token is a run of
non-whitespace), so both projects can be checked without an embedder.
## Instruction files, stacked
Instruction files are **collected from the boundary down to the start directory, outermost first**:
every directory on the way may contribute one, and all of them go to the model — the root's, then a
section's, then a repository's. (Before 3.0.0 the CLI took only the nearest one.)
- **The names**, in order of preference within one directory: `AGENTS.md`, then `CLAUDE.md`. A
directory contributes the first of them that is a file the session can read; never both.
- **Only what the session can read is loaded.** A file at `none` — in a passage, or taken away under
a granted directory — is not loaded, and the walk goes on below it: the next directory down may
still contribute. A file granted on its own (`/AGENTS.md` ticked while `/` is not) is loaded.
- A start directory outside the boundary is read as the boundary.
- On a device the boundary is the project root, a file counts only when it really is a file inside
the project (a link to elsewhere is not read), and the CLI's global `AGENTS.md` comes before the
chain, the `instructions` named in its configuration after it.
- **Below the start directory**, a file under a directory with its own instruction file hands the
model that file the first time something under it is read, once per session (the CLI's "nearby
instructions"), by the same reading rules.
- Each is given to the model under its path relative to the boundary.
`conformance/instructions.json` gives the cases, including the passage.
## Skills
A skill is a directory with a `SKILL.md` (frontmatter `name`, `description`), and `skills_list`,
`skill_view` and `skill_manage` are its meaning over files on both sides: the index in the prompt
(`prompts/blocks/skills.md`), `skill_view` to load one, `skill_manage` to make or change one, which
checks the frontmatter, the name and the size; `edit` works on the file too. Neither side syncs
skills on its own.
**Where they are found**, in order of precedence — a name found in an earlier place wins, and the
later one is not offered:
| Side | Places, the most specific first |
|---|---|
| chat | the `skills/` directory of every directory from the start directory up to the boundary, the nearest first, **the root's excluded**; then the person's own `/home/<user>/skills/`; then the organisation's `/skills/` (a skills repository mounted there, or a plain directory) |
| device | the project's `.agent/skills/` (a trusted project); then the user's own skills directory; then the directories the configuration adds, in its order |
So on both sides the session's own place comes first, the person's next, what is shared last. Only
what the session can read is a skill there; `skill_manage` makes a new one in the person's own
place.
**A skill's name**, which `skill_manage` checks when it makes or renames one: lowercase letters,
digits and `-`, starting with a letter or a digit, at most 64 characters —
`^[a-z0-9][a-z0-9-]{0,63}$`, the Agent Skills format's rule. A `SKILL.md` made elsewhere whose name
does not fit is still offered when its name is one line of at most 64 characters; a name is looked
up ignoring case, with `_` and spaces read as `-`.
## Memory
Memory is **records, not files**, and **the server keeps them for both sides**: a chat's memory is
its data group's partition, and a device logged in to an instance reads and writes the same records
through the instance (`acp.md`, *The HTTP API*), for a session the web UI opened in that chat's data
group, and for a session of its own in the group of the session's model. The `memory` tool is the
same tool on both sides (`tools/memory.json`); what each offers of its targets is its `narrow`.
+64 -1
View File
@@ -23,7 +23,7 @@
}
},
"wrap_up": {
"about": "One more request with no tools and a user message saying which limit was reached: answer now — what is done, what is not, what would come next. The transcript says the prompt was stopped, so an answer given because the room ran out is never mistaken for one the model chose to give.",
"about": "One more request, with the session's tool list unchanged (context.md §10, rule 4) and a user message saying which limit was reached: answer now — what is done, what is not, what would come next. The transcript says the prompt was stopped, so an answer given because the room ran out is never mistaken for one the model chose to give. A tool call in that reply is not run.",
"message": "The {{limit}} for this prompt has been reached and the tools have been withdrawn. Answer now from what you have: what is done, what is not, and what would come next."
},
"nudges": {
@@ -60,5 +60,68 @@
"default": 50
},
"lifecycle": "A subagent's own background commands stop when it ends, and its report says so. A session's jobs stop when the session is deleted. A front end asked to exit while jobs run asks the person first; one with nobody to ask waits for the jobs the prompt started, then stops the rest."
},
"context": {
"about": "Context hygiene (spec 3.0.0, the prefix from 3.1.0; context.md): every measure acts when a result is inserted, and nothing already sent to the model is ever rewritten — changing earlier context throws away the prompt cache from that point. conformance/context.json gives the texts.",
"ids": {
"about": "Every tool result gets an id: `#` and its number in the session, counting from 1, in lowercase hex, at least 4 digits (`#0001`, `#000a`, `#10000`) — unique in the session and never reused (not after a compaction either), so `recall` finds any of them. It is the first thing in the result, in brackets.",
"digits": 4
},
"read_header": "[{{id}} {{path}} lines {{from}}-{{to}} of {{of}} sha {{sha}}]",
"read_header_empty": "[{{id}} {{path}} empty sha {{sha}}]",
"edit_header": "[{{id}} {{path}} sha {{sha}}]",
"header": "[{{id}}]",
"unchanged": "Unchanged since {{earlier}}, which already shows lines {{from}}-{{to}}.",
"same_result": "Same result as {{earlier}}.",
"elided": "[{{n}} lines elided, full output {{id}}]",
"created": "Created, {{n}} lines.",
"no_result": "No stored result {{target}} in this session.",
"recall_header": "[{{id}} recall {{target}} lines {{from}}-{{to}} of {{of}}]",
"sha_digits": 8,
"cap": {
"about": "A result over max_lines lines or max_chars characters keeps its head (as many whole lines as fit both head limits) and its tail (likewise, from what is left), with the elided line between. When even the first line does not fit head_chars, it is cut to it and ends ` […cut]`, and the elided line follows however many whole lines it counts (0 included): it is there whenever anything was left out. Not applied to read (its own window: 2000 lines from an offset) or recall (likewise). The full output is stored with the session.",
"max_lines": 400,
"max_chars": 40000,
"head_lines": 200,
"head_chars": 20000,
"tail_lines": 100,
"tail_chars": 10000
},
"dedupe": {
"about": "by_hash: a read whose file (by its absolute path) has the same content hash as an earlier full read in the table, and whose lines lie wholly inside that read's, is answered with `unchanged`. by_result: a call with the same arguments as an earlier one in the table, whose result is the same text, is answered with `same_result`. Stubs point at the full result, never at another stub. The table is the full results in the request being built — never one the request does not carry (a chat's earlier reply, a turn undone) — and compaction empties it.",
"by_hash": [
"read"
],
"by_result": [
"list",
"glob",
"grep",
"search"
]
},
"compaction": {
"about": "The one deliberate break of the cache. After the summary the harness adds the table of the reads the summary covers — the full reads made since the previous compaction, in turns still in the history being summarised, whether or not the request still carries them; of those the newest full read of each file, newest last, at most `rows` — and then empties the dedupe table, so no stub ever points at content that is gone from the context. No reads: no table.",
"heading": "## Files read before this summary",
"line": "Read a file again for its content; recall shows a result by its id.",
"rows": 50
},
"reasoning": "Earlier turns' reasoning is left to the model's chat template: the harness sends the history as it does, neither stripping reasoning the template keeps nor adding what it drops.",
"note": {
"about": "A note on the next user message (context.md §10, rule 3): what changed since the system prompt was made, after the person's words. Lines in this order, each only when it applies. `plan_ended` is the mode block for a session that leaves plan mode for a mode with no text of its own (a chat). `plan_own` is the plan block for a session that does not resend earlier tool results (a chat): the model's own plan, as its tools left it. `personal` is the {{path}} of the instructions block for the person's own instructions.",
"open": "<session-note>",
"close": "</session-note>",
"date": "Today is {{date}}.",
"mode": "The permission mode is now {{mode}}:\n{{mode_text}}",
"plan_ended": "Plan mode has ended: make the changes the work needs; nothing is refused for planning any more.",
"plan": "The person changed the plan; it is now:\n{{plan}}",
"plan_own": "Your plan, as your plan tools left it:\n{{plan}}",
"memory": "The person changed what is remembered; it is now:\n{{memory}}",
"instructions": "{{path}} changed; it now reads:\n{{text}}",
"personal": "the person's own instructions",
"personality": "The person chose a different personality:\n{{personality}}",
"mcp": "Instructions from an MCP server that connected since the session began:\n{{instructions}}",
"empty": "(none)"
},
"mcp_wait_ms": 3000
}
}
+11 -1
View File
@@ -11,6 +11,10 @@
"glob": "allow",
"grep": "allow",
"list": "allow",
"search": "allow",
"recall": "allow",
"show": "allow",
"python": "allow",
"ask_user": "allow",
"plan_submit": "allow",
"task": "allow",
@@ -74,6 +78,12 @@
"tree *-o*": "ask",
"tree *-R*": "ask",
"file *-C*": "ask"
}
},
"commit": "ask",
"branch": "ask",
"pr": "ask",
"merge": "ask",
"tag": "ask",
"release": "ask"
}
}
+3 -1
View File
@@ -4,6 +4,7 @@
"order_about": "Loosest first. A project or a subagent may make the mode stricter, never looser; switching to a looser mode is always asked.",
"aliases": { "unrestricted": "auto" },
"floor": "The hardline (hardline.json) and the protected paths are refused in every mode, auto included.",
"confirm": "Spec 3.0.0: a tool with `confirm: always` (merge, tag, release) is put to the person before it runs, in every mode, auto included, as an approval card naming the call and what it reaches; it runs on a yes only. No rule allows it and no approval is remembered (an approval card offers once, never always). With nobody to answer, it is refused, as unattended says.",
"modes": {
"manual": {
"summary": "Everything not already allowed is asked.",
@@ -31,7 +32,8 @@
"read": "rules",
"write": "allow only for files under the plans directory; anything else is refused",
"execute": "only what the rules already allow; anything else is refused",
"interact": "rules"
"interact": "rules",
"refused": "Plan mode: nothing changes but the plan. Put what this would do into the plan with plan_write or plan_update; it can be done once the person approves the plan."
}
},
"unattended": "With nobody to answer, whatever a mode would ask is refused instead, and the model is told so up front (prompts/modes/unattended.md)."
+9
View File
@@ -0,0 +1,9 @@
<!-- LLeMbas CLI and LLeMbas, spec 3.0.0: how tool results are kept short (context.md). -->
# Tool results
- Every tool result starts with its id in brackets, like `[#0012]`. A read's bracket also says the path, the lines shown, the file's length and a short hash of its content: `[#0012 src/app.ts lines 1-200 of 340 sha 1c9e04ab]`.
- Reading a file that has not changed since an earlier read in this conversation returns a stub instead of the lines: `Unchanged since #0012, which already shows lines 1-200.` The content is in that earlier result. Use it; do not read the file again to see it.
- A listing or search repeated with the same result returns `Same result as #0026.` in the same way.
- Long output is shortened to its beginning and its end, with `[1234 lines elided, full output #0020]` between them.
- `recall` with an id shows that stored result in full, or from a line on: for lines left out of a shortened output, or for a result from before the conversation was summarised. Nothing is lost; recall only what you need.
- An edit answers with the diff of the change, not the whole file. The earlier read with that diff applied is the file now: read it again only for lines you have not seen.
- After the conversation is summarised, earlier results are no longer in front of you. The summary ends with a table of the files read before it, with their ids; a file read again then comes back in full.
+10
View File
@@ -0,0 +1,10 @@
<!-- LLeMbas, spec 3.0.0: the file system a chat works in (filesystem.md). -->
# Files
- You work in the user's file system, through the file tools. Relative paths start at {{start}}; absolute paths start at `/`.
- What this session can reach, and how: {{view}}. Nothing else exists here. A path below one of these is reached by its full path; a directory above one that is not itself listed cannot be listed, read or written.
- A listing ends a directory with `/`, marks a repository mounted from a forge `[mount]`, and an entry you can read but not change `[read-only]`.
- Read-only means read but not changed. Stage means changes are kept in this session and, in a repository, not committed. Commit means that, in a repository, the changes may also be committed and pushed to the forge.
- {{repositories}}
- Deleting moves a file to the trash, from which the user can restore it. Still, delete only what you were asked to, or what you made yourself.
- {{show}}
- {{python}}
+26 -13
View File
@@ -11,7 +11,7 @@
"intro": "the opening line: what the model is here to help with on this surface (the CLI: software engineering and running the project; LLeMbas: whatever the person is working on)",
"surface": "where the output is shown and how it is rendered (a terminal, monospace, GitHub-flavoured markdown; a web page rendering markdown)",
"tool_list": "LLeMbas: the exact tools offered on this request, as a sentence; the CLI: empty",
"file_tools": "the CLI: the bullet about using read, edit, write, grep and glob rather than shell commands; LLeMbas: empty",
"file_tools": "the bullet about using the dedicated file tools — read, edit, write, grep, glob — rather than shell commands: the CLI's, and since spec 3.0.0 LLeMbas's in a chat as well (where there is no shell, the bullet says the file tools are the way to the files); empty for a session without file tools",
"plan_tools": "the bullet about planning with plan_write from the start and keeping the plan current with plan_update, and saying so with ask_user when the plan turns out wrong; empty for a subagent, which has no plan tools",
"plan_context": "the current plan, rendered with its ids, under a heading, with the sentence to work to it and quote the ids; empty when there is none",
"ask_tool": "the bullet about asking with ask_user when a decision is the user's; empty for a subagent, which has nobody to ask",
@@ -183,25 +183,21 @@
"llembas_text": "identical"
},
"blocks/prototypes.md": {
"scope": "shared",
"llembas": [
"core.prototypes"
],
"llembas_text": "identical",
"scope": "cli",
"llembas": [],
"variables": {
"prototype_sources": "whether the page may load anything over https (the instance's network setting), as one sentence",
"prototype_surface": "where it is seen on this surface: empty in the web UI, which renders it; in a terminal, that it is saved as a file the person can open, and that the web UI renders it"
}
},
"why": "spec 3.0.0: a chat has no prototypes (show replaces them); the CLI keeps the text while a client still offers them over the link"
},
"blocks/library.md": {
"scope": "shared",
"llembas": [
"core.library"
],
"llembas_text": "identical",
"scope": "cli",
"llembas": [],
"variables": {
"knowledge_scope": "the sentence naming the knowledge bases this conversation is limited to (\"Knowledge searches here cover only: …. Finding nothing there means nothing is there, not that the library is empty.\"), or empty"
}
},
"why": "spec 3.0.0: a chat has no knowledge bases (its files are searched with search); the CLI keeps its own local library"
},
"blocks/subagent.md": {
"scope": "shared",
@@ -227,6 +223,23 @@
"llembas": [],
"llembas_text": "identical",
"why": "LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment"
},
"blocks/context.md": {
"scope": "shared",
"llembas": [],
"llembas_text": "differs",
"why": "not adopted yet (spec 3.0.0)"
},
"blocks/workspace.md": {
"scope": "llembas",
"llembas": [],
"variables": {
"start": "the session's start directory, as a code span",
"view": "every path granted to this session with its level (read-only, stage, commit), as a list in one sentence, a mount named as a repository with its branch",
"repositories": "when the view holds a repository from a forge: the sentence saying changes there collect in this session's working copy until `commit`, that a branch moved on the forge refuses the commit and shows the difference, and that merge, tag and release always ask the user; otherwise empty",
"show": "when the session has a screen (`show` is offered): the sentence saying to open a file the user should see with show — a page, a chart, a document you made — rather than pasting it; otherwise empty",
"python": "when `python` is offered: the sentence saying quick calculations and file work can be done in Python in the user's browser, and that it cannot run when no browser page is open; otherwise empty"
}
}
}
}
-2
View File
@@ -23,6 +23,4 @@ Leave out pleasantries, retracted ideas and anything already superseded. Do not
{{previous_summary}}
## Transcript
{{transcript}}
+1
View File
@@ -1,6 +1,7 @@
{
"name": "apply_patch",
"scope": "cli",
"sessions": "agent",
"risk": "write",
"exclusive": false,
"block": "edit",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "ask_user",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": true,
"block": "tool",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "bash",
"scope": "cli",
"sessions": "agent",
"risk": "execute",
"exclusive": false,
"block": "shell",
+39
View File
@@ -0,0 +1,39 @@
{
"name": "branch",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"unavailable": "No repository from a forge is open to this session, so there is nothing to branch here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Create a branch in a repository (a mount), from `from` (default: the branch this session works on), and switch this session's working copy of it to the new branch. Staged changes come along.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"name": {
"type": "string",
"description": "The new branch's name"
},
"from": {
"type": "string",
"description": "Branch, tag or commit to start from"
}
},
"required": [
"path",
"name"
]
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "commit",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"unavailable": "No repository from a forge is open to this session, so there is nothing to commit here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Send the staged changes of a repository (a mount) to the forge as one commit on the branch this session works on, under the user's account. Every change this session made in that repository since its last commit goes in, unless `paths` names some of them.\n\n- Refused when the branch has moved on the forge since this session's working copy was based on it: the answer shows what changed there. Look at it, then commit again.\n- Write the message as the repository's history does: a short subject line, then why, where it is not obvious.\n- Needs commit access to the repository in this session. With stage access the changes stay staged, for the user to commit.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"message": {
"type": "string",
"description": "The commit message"
},
"paths": {
"type": "array",
"items": {
"type": "string"
},
"description": "Only these changed paths (default: every staged change)"
}
},
"required": [
"path",
"message"
]
}
}
+1
View File
@@ -1,6 +1,7 @@
{
"name": "decisions",
"scope": "cli",
"sessions": "agent",
"risk": "interact",
"exclusive": false,
"block": "tool",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "edit",
"scope": "cli",
"scope": "shared",
"sessions": "both",
"risk": "write",
"exclusive": false,
"block": "edit",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "glob",
"scope": "cli",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "search",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "grep",
"scope": "cli",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "search",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "job_list",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "tool",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "job_output",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "tool",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "job_send",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "job_stop",
"scope": "shared",
"sessions": "both",
"risk": "execute",
"exclusive": false,
"block": "tool",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "knowledge_get",
"scope": "shared",
"scope": "cli",
"sessions": "agent",
"risk": "read",
"exclusive": false,
"block": "read",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "knowledge_search",
"scope": "shared",
"scope": "cli",
"sessions": "agent",
"risk": "read",
"exclusive": false,
"block": "search",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "list",
"scope": "cli",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "search",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "memory",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
+46
View File
@@ -0,0 +1,46 @@
{
"name": "merge",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": true,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"confirm": "always",
"unavailable": "No repository from a forge is open to this session, so there is nothing to merge here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Merge a pull request on the forge. The user is always asked first, in every mode, and shown the pull request: nothing is merged without their yes.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"number": {
"type": "integer",
"minimum": 1,
"description": "The pull request's number"
},
"method": {
"type": "string",
"enum": [
"merge",
"squash",
"rebase"
],
"description": "How to merge (default: the repository's setting)"
}
},
"required": [
"path",
"number"
]
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "mkdir",
"scope": "llembas",
"sessions": "chat",
"risk": "write",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Make a directory, and any missing directories above it. Making one that already exists is not an error.\n\n- `path` may be absolute or relative to the working directory.\n- In a repository (a mount), an empty directory stays in this session's working copy; a commit leaves empty directories out.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory to make"
}
},
"required": [
"path"
]
}
}
+1
View File
@@ -1,6 +1,7 @@
{
"name": "multiedit",
"scope": "cli",
"sessions": "agent",
"risk": "write",
"exclusive": false,
"block": "edit",
+31
View File
@@ -0,0 +1,31 @@
{
"name": "mv",
"scope": "llembas",
"sessions": "chat",
"risk": "write",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Move or rename a file or directory. A directory moves with everything in it.\n\n- `from` and `to` may be absolute or relative to the working directory; `to` must not exist yet.\n- Inside one repository (a mount) the move is staged like any other change. Between a repository and the rest of the file system, or between two repositories, it is refused: read the file and write it where it should go instead.",
"parameters": {
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "What to move"
},
"to": {
"type": "string",
"description": "The new path, including the name"
}
},
"required": [
"from",
"to"
]
}
}
+2 -8
View File
@@ -1,6 +1,7 @@
{
"name": "note_manage",
"scope": "shared",
"scope": "cli",
"sessions": "agent",
"risk": "interact",
"exclusive": false,
"block": "tool",
@@ -53,12 +54,5 @@
"required": [
"action"
]
},
"narrow": {
"llembas": {
"drop": [
"scope"
]
}
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "note_view",
"scope": "shared",
"scope": "cli",
"sessions": "agent",
"risk": "read",
"exclusive": false,
"block": "read",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "notes_search",
"scope": "shared",
"scope": "cli",
"sessions": "agent",
"risk": "read",
"exclusive": false,
"block": "search",
+5
View File
@@ -1,10 +1,15 @@
{
"name": "plan_submit",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": true,
"block": "tool",
"purpose": false,
"requires": [
"plan"
],
"unavailable": "Not in plan mode: plan_submit is how a plan made in plan mode is put to the person for approval. Carry on with the work; keep the plan current with plan_update.",
"aliases": {
"cli": [
"plan_exit"
+1
View File
@@ -1,6 +1,7 @@
{
"name": "plan_update",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "plan",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "plan_write",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "plan",
+46
View File
@@ -0,0 +1,46 @@
{
"name": "pr",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"unavailable": "No repository from a forge is open to this session, so there is nothing to pr here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Open a pull request on the forge, from `head` (default: the branch this session works on) into `base` (default: the repository's default branch). Only committed changes are in it: commit first.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"title": {
"type": "string"
},
"body": {
"type": "string",
"description": "What it changes and why"
},
"head": {
"type": "string",
"description": "The branch with the changes"
},
"base": {
"type": "string",
"description": "The branch to merge into"
}
},
"required": [
"path",
"title"
]
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"name": "python",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": false,
"block": "shell",
"purpose": "purpose",
"requires": [
"page"
],
"unavailable": "Python cannot run now: no open browser page is attached to this chat.",
"aliases": {
"cli": [],
"llembas": []
},
"variables": {
"packages": "the packages the instance provides beyond the standard library, as a list, or \"none\" — implementation-defined; LLeMbas 2.0: none (the standard library only)",
"seconds": "the time limit of one call, in seconds — implementation-defined; LLeMbas 2.0: 30",
"memory": "the memory limit of one call, with its unit — implementation-defined; LLeMbas 2.0: 512 MB",
"output": "how much printed output comes back, with its unit — implementation-defined; LLeMbas 2.0: 20,000 characters"
},
"description": "Run Python for calculations and for working with files — parse JSON or CSV, transform text, compute. It runs in the user's browser (Pyodide), sandboxed: no network, no shell, no packages beyond the standard library and these: {{packages}}. Returns what the code printed and the value of its last expression.\n\n- `files` are copied in before the run, at the same paths, from what this session can read.\n- Files the code writes come back only when listed in `save`: each is written as the write tool would write it, with this session's permissions.\n- At most {{seconds}} seconds and {{memory}} of memory per call; printed output past {{output}} is cut.\n- Every call starts fresh: nothing is kept between calls.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The Python code to run"
},
"files": {
"type": "array",
"items": {
"type": "string"
},
"description": "Files to copy in before the run"
},
"save": {
"type": "array",
"items": {
"type": "string"
},
"description": "Files the code writes that are to be saved back"
}
},
"required": [
"code"
]
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "read",
"scope": "cli",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "read",
+36
View File
@@ -0,0 +1,36 @@
{
"name": "recall",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "read",
"purpose": false,
"aliases": {
"cli": [],
"llembas": []
},
"description": "Show a stored tool result again, in full, by its id (`#a3f2`): the lines a shortened output left out, or a result from before the conversation was summarised. Every result of this session is kept, so nothing is lost.\n\n- Returns up to 2000 lines from `offset` (1-indexed); call again with a larger offset for more.\n- An unchanged file is not worth recalling when its earlier result is still above: that result is the content.",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The result's id, e.g. #a3f2"
},
"offset": {
"type": "integer",
"minimum": 1,
"description": "First line to return, 1-indexed"
},
"limit": {
"type": "integer",
"minimum": 1,
"description": "How many lines (default 2000)"
}
},
"required": [
"id"
]
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"name": "release",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": true,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"confirm": "always",
"unavailable": "No repository from a forge is open to this session, so there is nothing to release here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Publish a release on the forge from a tag that already exists, with its notes. Make the tag first (tag): a release is never made for a tag that does not exist. The user is always asked first, in every mode.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"tag": {
"type": "string",
"description": "The existing tag"
},
"title": {
"type": "string"
},
"notes": {
"type": "string",
"description": "The release notes (usually the tag's message)"
},
"prerelease": {
"type": "boolean"
},
"draft": {
"type": "boolean"
}
},
"required": [
"path",
"tag",
"title",
"notes"
]
}
}
+1
View File
@@ -1,6 +1,7 @@
{
"name": "report",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
+26
View File
@@ -0,0 +1,26 @@
{
"name": "rm",
"scope": "llembas",
"sessions": "chat",
"risk": "write",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Delete a file or directory by moving it to the trash, from which the user can restore it. A directory goes with everything in it: list it first.\n\n- `path` may be absolute or relative to the working directory.\n- In a repository (a mount) the deletion is staged in this session's working copy and reaches the repository only with a commit.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File or directory to delete"
}
},
"required": [
"path"
]
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "search",
"scope": "llembas",
"sessions": "chat",
"risk": "read",
"exclusive": false,
"block": "search",
"purpose": "purpose",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Search the files this session can see, by meaning and by words together: the best passages first, each with its path and line range. Use it to find where something is discussed or answered when you do not know the exact words; use grep for exact text or a pattern, glob for file names.\n\n- `path` limits the search to a directory (default: everything this session can see).\n- A passage is an extract. Read the file around it before answering from it when the answer turns on detail, and cite the path.\n- Only indexed files are searched: text, Markdown, code, JSON and the text of PDF and DOCX files, up to a size limit. A file just written is found once it has been indexed.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What to look for, in words: a question or the topic"
},
"path": {
"type": "string",
"description": "Directory to search (default: everything this session can see)"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"description": "How many passages (default 10)"
}
},
"required": [
"query"
]
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "session_search",
"scope": "cli",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "search",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "settings",
"scope": "cli",
"sessions": "agent",
"risk": "interact",
"exclusive": false,
"block": "tool",
+35
View File
@@ -0,0 +1,35 @@
{
"name": "show",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"requires": [
"display"
],
"unavailable": "Nothing can be shown now: this session has no screen to show a file on. Give the user the path instead.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Open a file in the user's viewer, beside the conversation, without them having to look for it: Markdown rendered, code highlighted, plain text as it is, and HTML as a page in a sandboxed frame that cannot reach this app, the user's data or anything the instance does not allow. Use it when the user should see the file itself — a page or a chart you made, a document you wrote — not for files you only read.\n\n- `path` may be absolute or relative to the working directory. The file must exist and be readable in this session: write it first.\n- `line` scrolls a text file to that line.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File to show"
},
"line": {
"type": "integer",
"minimum": 1,
"description": "Line to scroll to (text files)"
}
},
"required": [
"path"
]
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "skill_manage",
"scope": "shared",
"sessions": "both",
"risk": "write",
"exclusive": false,
"block": "tool",
@@ -42,7 +43,7 @@
},
"name": {
"type": "string",
"description": "The skill (lowercase letters, digits, - _ .; up to 64)"
"description": "The skill (lowercase letters, digits and -, starting with a letter or digit; up to 64)"
},
"description": {
"description": "create: when to use the skill, one sentence, the trigger first: \"Use when …\"",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "skill_view",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "read",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "skills_list",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "search",
+45
View File
@@ -0,0 +1,45 @@
{
"name": "tag",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": true,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"confirm": "always",
"unavailable": "No repository from a forge is open to this session, so there is nothing to tag here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Create an annotated tag on the forge, at `at` (default: the newest commit of the branch this session works on), its message being the release note. The user is always asked first, in every mode.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"name": {
"type": "string",
"description": "The tag, e.g. v1.2.0"
},
"message": {
"type": "string",
"description": "The tag's message"
},
"at": {
"type": "string",
"description": "Branch or commit to tag"
}
},
"required": [
"path",
"name",
"message"
]
}
}
+1
View File
@@ -1,6 +1,7 @@
{
"name": "task",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "task",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "tasks",
"scope": "cli",
"sessions": "agent",
"risk": "interact",
"exclusive": false,
"block": "tool",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "view_image",
"scope": "cli",
"sessions": "agent",
"risk": "read",
"exclusive": false,
"block": "read",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "web_fetch",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "web",
+1
View File
@@ -1,6 +1,7 @@
{
"name": "web_search",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "web",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "write",
"scope": "cli",
"scope": "shared",
"sessions": "both",
"risk": "write",
"exclusive": false,
"block": "edit",
+7 -19
View File
@@ -221,8 +221,9 @@ if [ "$starter" -eq 1 ] && [ ! -e "$CONFIG/config.yaml" ]; then
# The full list: https://llembas.eu/docs/. An editor with a YAML language server
# completes and checks keys as you type (the first line points it at the schema).
# The model to start with, as connection/model from connections.yaml.
# model: local/qwen3
# The model to start with, by its id on the LLeMbas server you log in to (lembas models lists
# them). Unset: the server's default for your account.
# model: deepseek/deepseek-flash
# manual (asks before changes) | edit (changes files in the project freely) | plan
mode: manual
@@ -237,27 +238,14 @@ fi
if [ "$starter" -eq 1 ] && [ ! -e "$CONFIG/connections.yaml" ]; then
(umask 077 && cat >"$CONFIG/connections.yaml" <<'YAML'
# yaml-language-server: $schema=schema/connections.schema.json
# Where the models are. Global only: a project can never point LLeMbas CLI elsewhere.
# Keys are never written here in plain text: {env:VAR}, {file:~/path}, or key_cmd.
# Then set `model: <connection>/<model>` in config.yaml, and check with `lembas models`.
# The LLeMbas server this machine is logged in to. Global only: a project can never point
# LLeMbas CLI elsewhere. Written by `lembas login <address>`, not by hand; the models come from
# the server at every start (`lembas models` lists them).
connections: {}
#
# connections:
# local: # llama.cpp / llama-swap / vLLM / LM Studio — OpenAI-compatible
# dialect: openai-chat
# base_url: http://127.0.0.1:8080/v1
# models:
# qwen3: { context: 131072, max_output: 65536 }
# anthropic:
# dialect: anthropic
# base_url: https://api.anthropic.com
# api_key: "{env:ANTHROPIC_API_KEY}"
# models:
# claude-sonnet-5: { context: 200000 }
YAML
)
chmod 600 "$CONFIG/connections.yaml"
say " wrote $CONFIG/connections.yaml (0600) — add a connection there"
say " wrote $CONFIG/connections.yaml (0600) — lembas login <address> fills it in"
fi
# The schemas for editors follow the binary, so they are rewritten on every install.
"$TARGET" config schema >/dev/null && say " wrote $CONFIG/schema/ (JSON Schemas for editors)"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "lembas-cli",
"version": "1.5.0",
"description": "LLeMbas CLI: a terminal coding agent and project manager — OpenTUI, any LLM API, memory, skills, MCP, git-native.",
"version": "2.0.0-beta.2",
"description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
"license": "MIT",
"private": true,
"type": "module",
+5 -8
View File
@@ -3,11 +3,11 @@
"type": "object",
"properties": {
"model": {
"description": "The model a session starts with: `connection/model`, a connection from connections.yaml and one of its models.",
"description": "The model a session starts with: one the LLeMbas server you are logged in to serves, by its id there (`deepseek/deepseek-flash`). Default: the server's default for your account.",
"type": "string"
},
"small_model": {
"description": "`connection/model` for small jobs — session titles, commit messages. Default: the session's model. On a server that holds one model at a time (llama-swap), another model here swaps.",
"description": "The model for small jobs — session titles, commit messages, by its id on the server. Default: the session's model.",
"type": "string"
},
"titles": {
@@ -241,16 +241,13 @@
"additionalProperties": false
},
"compaction": {
"description": "Automatic context management: past auto_at of the window, old tool outputs are pruned, then the conversation is compacted. auto_at: 1 turns it off.",
"description": "Automatic context management: past auto_at of the window, the conversation is compacted. auto_at: 1 turns it off. (Old tool outputs are never cut afterwards: that would make the server read the whole conversation again.)",
"type": "object",
"properties": {
"auto_at": {
"type": "number",
"minimum": 0.1,
"maximum": 1
},
"prune": {
"type": "boolean"
}
},
"additionalProperties": false
@@ -869,7 +866,7 @@
}
},
"embedding": {
"description": "Global config only. connection/model of an embedding model (OpenAI-shaped /embeddings, or Ollama): knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it.",
"description": "Global config only. An embedding model the LLeMbas server serves, by its id there: knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it.",
"type": "string"
},
"effort": {
@@ -999,7 +996,7 @@
"type": "boolean"
},
"library": {
"description": "Global config only. local (default): memory, notes, skills and knowledge on this machine. lembas: the account's on the LLeMbas instance you are logged in to, through its /mcp — the same tools its chats have (needs a login with the library scope).",
"description": "Global config only. Where memory lives. lembas (the default when the login may reach it): the account's memory records on the LLeMbas server, through its /mcp — the same records its chats have. local: this machine's memory files. Notes, knowledge bases and skills are this machine's either way.",
"type": "string",
"enum": [
"local",
+188 -527
View File
@@ -4,564 +4,225 @@
"properties": {
"connections": {
"default": {},
"description": "Each LLM endpoint by a name of your choosing; a model is then `name/model-id`. `type: webui` is a LLeMbas instance, whose models come from it.",
"description": "The LLeMbas server this machine is logged in to, as `lembas login` writes it: its address and this machine's token. Its models come from it at every start.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "webui",
"description": "A LLeMbas instance: its models, their settings, its voice and its web search."
},
"url": {
"type": "string",
"description": "The instance's address, e.g. https://ai.example.org (no /v1)."
},
"api_key": {
"description": "This machine's token, as login wrote it: {file:~/.config/lembas/lembas/<name>.key}.",
"type": "string"
},
"tls": {
"description": "For an instance behind a private CA: `ca`, the PEM file that signed its certificate.",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "webui",
"description": "A LLeMbas instance: its models, their settings, its voice and its web search."
},
"url": {
"type": "string",
"description": "The instance's address, e.g. https://ai.example.org (no /v1)."
},
"api_key": {
"description": "This machine's token, as login wrote it: {file:~/.config/lembas/lembas/<name>.key}. Or key_cmd.",
"ca": {
"type": "string"
},
"key_cmd": {
"type": "string"
},
"tls": {
"description": "For an instance behind a private CA: `ca`, the PEM file that signed its certificate.",
"type": "object",
"properties": {
"ca": {
"type": "string"
},
"insecure": {
"type": "boolean"
}
},
"additionalProperties": false
},
"timeout": {
"description": "Seconds of silence allowed in a reply, as for any connection (default 600).",
"type": "number",
"exclusiveMinimum": 0
},
"quirks": {
"description": "Server oddities; auto is right almost always.",
"type": "object",
"properties": {
"stream_usage": {
"description": "Ask for token usage in the stream (stream_options); auto learns when a server refuses it.",
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"prompt_progress": {
"description": "Ask how far the server is through reading the prompt (llama.cpp's return_progress), shown while it reads; auto learns when a server refuses it, and one that ignores it shows nothing.",
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"think_tags": {
"description": "<think>…</think> in a reply is taken as reasoning; off leaves it as text.",
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"max_tokens_field": {
"description": "OpenAI reasoning models refuse `max_tokens`; most local servers only know it.",
"type": "string",
"enum": [
"max_tokens",
"max_completion_tokens"
]
}
},
"additionalProperties": false
},
"models": {
"description": "Only to change what the instance says about a model, here: each entry goes over the instance's. Usually absent.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"description": "A display name; the id is what is sent.",
"type": "string"
},
"family": {
"description": "Picks the prompt overlay: anthropic | gpt | gemini | local. Guessed from the id when absent.",
"type": "string"
},
"context": {
"description": "Total context window in tokens. 0 / absent = unknown: no percentage, no auto-compaction.",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"max_output": {
"description": "Max tokens per reply.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"temperature": {
"description": "Sampling temperature; absent = the server's default.",
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"description": "Nucleus sampling; absent = the server's default.",
"type": "number",
"minimum": 0,
"maximum": 1
},
"efforts": {
"description": "The model's own effort vocabulary, in offering order.",
"type": "array",
"items": {
"type": "string",
"enum": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
}
},
"effort": {
"description": "Default effort; `off` sends none.",
"anyOf": [
{
"type": "string",
"enum": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
},
{
"type": "string",
"const": "off"
}
]
},
"effort_style": {
"description": "openai-chat only: where the effort goes. llama.cpp drops top-level, so `both` is the default.",
"type": "string",
"enum": [
"top",
"kwargs",
"both"
]
},
"effort_map": {
"description": "anthropic/gemini: effort → thinking budget tokens.",
"type": "object",
"propertyNames": {
"type": "string",
"enum": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
},
"additionalProperties": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"vision": {
"description": "The model reads images: @image files, pasted paths and view_image.",
"type": "boolean"
},
"tools": {
"description": "false: a model without tool calls — it can only talk.",
"type": "boolean"
},
"cache": {
"description": "anthropic: prompt caching breakpoints.",
"type": "boolean"
},
"headers": {
"description": "Extra HTTP headers for this model's requests.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"body": {
"description": "Merged into every request body for this model.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"notes": {
"description": "What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching.",
"type": "string",
"maxLength": 300
},
"fallback": {
"description": "Other models (connection/model), in order, to switch to when this one's server cannot be reached at all — never once a reply has begun.",
"type": "array",
"items": {
"type": "string"
}
},
"single_session": {
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused.",
"type": "boolean"
},
"group": {
"description": "Models sharing one server that holds one model at a time (llama-swap), named by any string: a subagent on another model of the same group would unload this one, so it is refused. One connection's one_model_at_a_time is the same rule for all its models; this is for models one connection serves from several such servers (a LLeMbas instance).",
"type": "string"
}
},
"additionalProperties": false
}
"insecure": {
"type": "boolean"
}
},
"required": [
"type",
"url"
],
"additionalProperties": false
},
{
"timeout": {
"description": "Seconds of silence allowed in a reply, as for any connection (default 600).",
"type": "number",
"exclusiveMinimum": 0
},
"quirks": {
"description": "Server oddities; auto is right almost always.",
"type": "object",
"properties": {
"dialect": {
"stream_usage": {
"description": "Ask for token usage in the stream (stream_options); auto learns when a server refuses it.",
"type": "string",
"enum": [
"openai-chat",
"responses",
"anthropic",
"gemini",
"ollama"
],
"description": "The API it speaks: openai-chat (llama.cpp, vLLM, LM Studio, OpenAI…), responses (OpenAI Responses), anthropic, gemini, ollama (its native /api/chat)."
},
"base_url": {
"type": "string",
"description": "Where the API is, e.g. https://api.openai.com/v1 or http://localhost:8080/v1."
},
"api_key": {
"description": "Never pasted: {env:NAME} or {file:~/path}. Or use key_cmd.",
"type": "string"
},
"key_cmd": {
"description": "A command whose output is the key, e.g. pass show openai.",
"type": "string"
},
"auth": {
"description": "How the key is sent. Default per dialect: anthropic x-api-key, gemini x-goog-api-key, others bearer.",
"type": "string",
"enum": [
"bearer",
"x-api-key",
"x-goog-api-key",
"none"
"auto",
"on",
"off"
]
},
"headers": {
"description": "Extra HTTP headers for every request.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"body": {
"description": "Merged into every request body.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"tls": {
"description": "TLS for endpoints behind a private CA. `ca` is a PEM file added to the trusted roots; `insecure` skips verification entirely (last resort). The system store is always trusted.",
"type": "object",
"properties": {
"ca": {
"type": "string"
},
"insecure": {
"type": "boolean"
}
},
"additionalProperties": false
},
"timeout": {
"description": "Seconds of silence allowed: waiting for the response, then between two pieces of a streamed reply. Not a limit on how long a reply may take. Default 600.",
"type": "number",
"exclusiveMinimum": 0
},
"discover": {
"description": "Merge ids and context lengths from GET /models.",
"type": "boolean"
},
"unload_url": {
"description": "llama-swap style: frees VRAM when switching away.",
"type": "string"
},
"one_model_at_a_time": {
"description": "The server holds one model at a time (llama-swap in front of one GPU): a subagent on another of its models would unload the session's, so it is refused. The session's own model may still be its own subagent.",
"type": "boolean"
},
"unload_method": {
"description": "How unload_url is called (default POST).",
"prompt_progress": {
"description": "Ask how far the server is through reading the prompt (llama.cpp's return_progress), shown while it reads; auto learns when a server refuses it, and one that ignores it shows nothing.",
"type": "string",
"enum": [
"GET",
"POST"
"auto",
"on",
"off"
]
},
"quirks": {
"description": "Server oddities; auto is right almost always.",
"type": "object",
"properties": {
"stream_usage": {
"description": "Ask for token usage in the stream (stream_options); auto learns when a server refuses it.",
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"prompt_progress": {
"description": "Ask how far the server is through reading the prompt (llama.cpp's return_progress), shown while it reads; auto learns when a server refuses it, and one that ignores it shows nothing.",
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"think_tags": {
"description": "<think>…</think> in a reply is taken as reasoning; off leaves it as text.",
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"max_tokens_field": {
"description": "OpenAI reasoning models refuse `max_tokens`; most local servers only know it.",
"type": "string",
"enum": [
"max_tokens",
"max_completion_tokens"
]
}
},
"additionalProperties": false
"think_tags": {
"description": "<think>…</think> in a reply is taken as reasoning; off leaves it as text.",
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"models": {
"default": {},
"description": "The models to offer, by the id the server knows them by.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"description": "A display name; the id is what is sent.",
"type": "string"
},
"family": {
"description": "Picks the prompt overlay: anthropic | gpt | gemini | local. Guessed from the id when absent.",
"type": "string"
},
"context": {
"description": "Total context window in tokens. 0 / absent = unknown: no percentage, no auto-compaction.",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"max_output": {
"description": "Max tokens per reply.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"temperature": {
"description": "Sampling temperature; absent = the server's default.",
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"description": "Nucleus sampling; absent = the server's default.",
"type": "number",
"minimum": 0,
"maximum": 1
},
"efforts": {
"description": "The model's own effort vocabulary, in offering order.",
"type": "array",
"items": {
"type": "string",
"enum": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
}
},
"effort": {
"description": "Default effort; `off` sends none.",
"anyOf": [
{
"type": "string",
"enum": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
},
{
"type": "string",
"const": "off"
}
]
},
"effort_style": {
"description": "openai-chat only: where the effort goes. llama.cpp drops top-level, so `both` is the default.",
"type": "string",
"enum": [
"top",
"kwargs",
"both"
]
},
"effort_map": {
"description": "anthropic/gemini: effort → thinking budget tokens.",
"type": "object",
"propertyNames": {
"type": "string",
"enum": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
},
"additionalProperties": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"vision": {
"description": "The model reads images: @image files, pasted paths and view_image.",
"type": "boolean"
},
"tools": {
"description": "false: a model without tool calls — it can only talk.",
"type": "boolean"
},
"cache": {
"description": "anthropic: prompt caching breakpoints.",
"type": "boolean"
},
"headers": {
"description": "Extra HTTP headers for this model's requests.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"body": {
"description": "Merged into every request body for this model.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"notes": {
"description": "What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching.",
"type": "string",
"maxLength": 300
},
"fallback": {
"description": "Other models (connection/model), in order, to switch to when this one's server cannot be reached at all — never once a reply has begun.",
"type": "array",
"items": {
"type": "string"
}
},
"single_session": {
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused.",
"type": "boolean"
},
"group": {
"description": "Models sharing one server that holds one model at a time (llama-swap), named by any string: a subagent on another model of the same group would unload this one, so it is refused. One connection's one_model_at_a_time is the same rule for all its models; this is for models one connection serves from several such servers (a LLeMbas instance).",
"type": "string"
}
},
"additionalProperties": false
}
"max_tokens_field": {
"description": "OpenAI reasoning models refuse `max_tokens`; most local servers only know it.",
"type": "string",
"enum": [
"max_tokens",
"max_completion_tokens"
]
}
},
"required": [
"dialect",
"base_url"
],
"additionalProperties": false
},
"models": {
"description": "Only to change what the instance says about a model, here: each entry goes over the instance's. Usually absent.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"description": "A display name; the id is what is sent.",
"type": "string"
},
"family": {
"description": "Picks the prompt overlay: anthropic | gpt | gemini | local. Guessed from the id when absent.",
"type": "string"
},
"context": {
"description": "Total context window in tokens. 0 / absent = unknown: no percentage, no auto-compaction.",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"max_output": {
"description": "Max tokens per reply.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"temperature": {
"description": "Sampling temperature; absent = the server's default.",
"type": "number",
"minimum": 0,
"maximum": 2
},
"top_p": {
"description": "Nucleus sampling; absent = the server's default.",
"type": "number",
"minimum": 0,
"maximum": 1
},
"efforts": {
"description": "The model's own effort vocabulary, in offering order.",
"type": "array",
"items": {
"type": "string",
"enum": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
}
},
"effort": {
"description": "Default effort; `off` sends none.",
"anyOf": [
{
"type": "string",
"enum": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
},
{
"type": "string",
"const": "off"
}
]
},
"effort_style": {
"description": "Where the effort goes in a request: top-level, in chat_template_kwargs, or both (the default: llama.cpp drops top-level).",
"type": "string",
"enum": [
"top",
"kwargs",
"both"
]
},
"vision": {
"description": "The model reads images: @image files, pasted paths and view_image.",
"type": "boolean"
},
"tools": {
"description": "false: a model without tool calls — it can only talk.",
"type": "boolean"
},
"headers": {
"description": "Extra HTTP headers for this model's requests.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"body": {
"description": "Merged into every request body for this model.",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"notes": {
"description": "What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching.",
"type": "string",
"maxLength": 300
},
"single_session": {
"description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused.",
"type": "boolean"
},
"group": {
"description": "Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused.",
"type": "string"
}
},
"additionalProperties": false
}
}
]
},
"required": [
"type",
"url"
],
"additionalProperties": false
}
}
},
"additionalProperties": false,
"title": "LLeMbas CLI connections.yaml",
"description": "~/.config/lembas/connections.yaml: the LLM endpoints. Global only. Keys as {env:NAME}, {file:path} or key_cmd — never pasted."
"description": "~/.config/lembas/connections.yaml: the LLeMbas server this machine is logged in to, as lembas login writes it. Global only."
}
+2 -2
View File
@@ -68,12 +68,12 @@ export async function drive(opts: { cols: number; rows: number; bin?: string; ho
}
export function fakeHome(script: Parameters<typeof fakeProvider>[0]) {
const fake = fakeProvider(script)
const fake = fakeProvider(script, { served: [{ id: "fake/coder" }, { id: "fake/other" }] })
const home = mkdtempSync(join(tmpdir(), "lembas-drive-"))
mkdirSync(join(home, ".config/lembas"), { recursive: true })
writeFileSync(
join(home, ".config/lembas/connections.yaml"),
`connections:\n fake:\n dialect: openai-chat\n base_url: ${fake.url}\n models:\n coder: { context: 32768, efforts: [low, high], effort: low }\n other: { context: 8192 }\n`,
`connections:\n fake:\n type: webui\n url: ${fake.url}\n api_key: smoke\n models:\n fake/coder: { context: 32768, efforts: [low, high], effort: low }\n fake/other: { context: 8192 }\n`,
{ mode: 0o600 },
)
writeFileSync(join(home, ".config/lembas/config.yaml"), "model: fake/coder\nupdate: { auto: off }\n")
+5 -5
View File
@@ -26,11 +26,11 @@ const v = run(["--version"])
check("--version", v.code === 0 && /^\d+\.\d+\.\d+/.test(v.out), v.err)
// 2. a headless run against a fake endpoint, with the session store on (FTS5 inside the binary)
const fake = fakeProvider([{ chunks: [delta({ content: "smoke ok" }), usage(5, 2)] }])
const fake = fakeProvider([{ chunks: [delta({ content: "smoke ok" }), usage(5, 2)] }], { served: [{ id: "fake/m" }] })
mkdirSync(join(home, ".config/lembas"), { recursive: true })
writeFileSync(
join(home, ".config/lembas/connections.yaml"),
`connections:\n fake:\n dialect: openai-chat\n base_url: ${fake.url}\n models:\n m: {}\n`,
`connections:\n fake:\n type: webui\n url: ${fake.url}\n api_key: smoke\n models:\n fake/m: {}\n`,
{ mode: 0o600 },
)
writeFileSync(join(home, ".config/lembas/config.yaml"), "model: fake/m\nupdate: { auto: off }\n")
@@ -46,10 +46,10 @@ check("sessions → recorded (sqlite + FTS5 in the binary)", s.code === 0 && s.o
// 2b. serve --stdio: the ACP agent inside the binary answers initialize, opens a session and
// streams a reply — the code the LLeMbas link runs too.
{
const f = fakeProvider([{ chunks: [delta({ content: "acp ok" })] }])
const f = fakeProvider([{ chunks: [delta({ content: "acp ok" })] }], { served: [{ id: "fake/m" }] })
writeFileSync(
join(home, ".config/lembas/connections.yaml"),
`connections:\n fake:\n dialect: openai-chat\n base_url: ${f.url}\n models:\n m: {}\n`,
`connections:\n fake:\n type: webui\n url: ${f.url}\n api_key: smoke\n models:\n fake/m: {}\n`,
{ mode: 0o600 },
)
const p = Bun.spawn([bin, "serve", "--stdio"], { cwd: home, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" })
@@ -181,7 +181,7 @@ for (const [cols, rows] of [
const d = await drive({ cols: 100, rows: 30, bin, home: h, cwd })
await d.waitFor("Trust this directory?")
await d.key("\r")
const setup = await d.waitFor("no model to talk to yet")
const setup = await d.waitFor("not logged in to a LLeMbas server")
await Bun.write(join(cfgDir, "connections.yaml"), connections)
await Bun.write(join(cfgDir, "config.yaml"), "model: fake/coder\nupdate: { auto: off }\n")
// The screen opens on the sign-in, its address focused: ctrl+r tries again from there.
+87 -24
View File
@@ -42,12 +42,13 @@ import type { Message } from "../provider/types.ts"
import type { Hub } from "./hub.ts"
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
import { isNote } from "../session/prefix.ts"
export const ACP_VERSION = 1
/** The `_lembas/*` extension's version (LLeMbas `api/devices.py:PROTOCOL`). 2: every
* feature beyond 1 is also named in `capabilities`, so a protocol 1 peer keeps working with
* what it knows (harness/acp.md). */
export const LEMBAS_PROTOCOL = 2
/** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3:
* LLeMbas CLI 2.0 and LLeMbas 2.0, which speak it alone — a 1.x peer (1 and 2) and a 2.0 one refuse
* each other, each saying what to update. Within 3, every feature is still named in `capabilities`. */
export const LEMBAS_PROTOCOL = 3
export interface Limits {
/** Directories a session may be opened in (and anything under them). Empty: none at all. */
@@ -209,14 +210,8 @@ export function contextOf(meta: any): SessionContext | undefined {
if (m.personalization === null) out.personalization = null
else if (m.personalization && typeof m.personalization === "object")
out.personalization = { personality: str(m.personalization.personality), personality_custom: str(m.personalization.personality_custom), instructions: str(m.personalization.instructions) }
if (m.library && typeof m.library === "object")
out.library = {
bases: (Array.isArray(m.library.bases) ? (m.library.bases as unknown[]) : [])
.filter((b): b is { id: unknown; name: unknown } => !!b && typeof b === "object")
.map((b) => ({ id: str(b.id), name: str(b.name) }))
.filter((b) => b.id || b.name)
.map((b) => ({ id: b.id || b.name, name: b.name || b.id })),
}
// `library` (an instance's knowledge bases, 1.1.0) is not read: the CLI's knowledge bases are its
// own (spec 3.0.0), and a 3.0.0 instance sends none.
if (m.prototypes && typeof m.prototypes === "object") out.prototypes = { offered: m.prototypes.offered === true, sources: str(m.prototypes.sources) }
return Object.keys(out).length ? out : undefined
}
@@ -274,6 +269,9 @@ export const CAPABILITIES = [
// `_lembas/jobs` and `_lembas/job/*` methods, `jobs` in `status`, `job` on an approval, and a
// note's turn marked `machine`.
"jobs",
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
// said it can show files; the device sends the file with `_lembas/show`.
"show",
] as const
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
@@ -372,7 +370,9 @@ export function planReplyFrom(answer: any, maxMode?: Mode): PlanReply {
}
/** Text the expander added to a user message (an @file, an image, a listing, a `!` command's output). */
const ATTACHMENT = /^<(file|directory|shell|image) /
// What the model was given beside the words typed — attachments, and a session note
// (session/prefix.ts) — is not shown as typed.
const ATTACHMENT = { test: (t: string) => /^<(file|directory|shell|image) /.test(t) || isNote(t) }
/** A stored session as turns the web UI can draw: each person's message, then what the
* model said, thought and ran in answer, up to the next one. A message's attachments are chips,
@@ -427,6 +427,9 @@ export class AcpAgent {
private get cards() {
return this.lembas && this.clientProtocol >= 2
}
/** The client shows files (`show: true` in `clientCapabilities._meta.lembas`, spec 3.0.0): the
* show tool is offered in the sessions it opens, and nowhere else. */
private shows = false
/** initialize has been answered: notifications about sessions may go out from now on. */
private ready = false
@@ -486,6 +489,8 @@ export class AcpAgent {
const said = p?.clientCapabilities?._meta?.lembas
this.lembas = Boolean(said)
this.clientProtocol = typeof said?.protocol === "number" ? said.protocol : 1
this.shows = said?.show === true
for (const s of this.sessions.values()) if (s.agent === this) this.equipShow(s)
// The sessions this link took over (the hub's) get their command set once the client
// knows the agent — after this answer, not before it.
setTimeout(() => {
@@ -576,17 +581,34 @@ export class AcpAgent {
const mode = L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted
const session: Session = { id: "", cwd, prompting: false, app: undefined as unknown as App, turns: new TurnLog() }
// No model-made title: the client names its own conversations (LLeMbas has its chat's), and a
// title request would be a second call on the account's credits for nothing.
// title request would be a second call the account pays for, for nothing.
// The chat's model and effort: `lembas_model` is the instance's own name for it, the
// one its /v1 serves; `model` a ref of this machine's, for an editor that knows them.
const model = this.refFor(p?._meta?.lembas_model) ?? (typeof p?._meta?.model === "string" ? p._meta.model : undefined)
// one its /v1 serves; `model` a ref of this machine's, for an editor that knows them. A session
// the client opens on its model runs on that model and no other (spec 3.0.0): one this device
// cannot speak to is refused, never answered on a model of the device's own.
const served = p?._meta?.lembas_model
const model = served !== undefined ? this.chatModel(served) : typeof p?._meta?.model === "string" ? p._meta.model : undefined
const effort = effortOf(p?._meta?.effort)
session.app = createApp({ cwd, mode, asker: this.asker(session), modelTitles: false, model, ...(effort !== undefined ? { effort } : {}) })
try {
session.app = createApp({ cwd, mode, asker: this.asker(session), modelTitles: false, model, ...(effort !== undefined ? { effort } : {}) })
} catch (e) {
throw new RpcError(INVALID_PARAMS, (e as Error).message)
}
if (served !== undefined) {
const why = this.notServed(session.app, model!)
if (why) {
session.app.discardIfEmpty()
void session.app.close()
throw new RpcError(INVALID_PARAMS, why)
}
}
// The store's id: the same session the terminals here list and can open.
const id = session.app.engine.sessionId ?? crypto.randomUUID()
session.id = id
session.turns = turnLog(id)
markRemote(session.app.store, id)
// The client's chat: its memory calls land in that chat's data group (acp.md *Memory*).
session.app.setScope(id)
this.takeContext(session, p)
this.sessions.set(id, session)
this.adopt(session)
@@ -596,19 +618,40 @@ export class AcpAgent {
return {
sessionId: id,
modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) },
_meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort ?? "off" } },
// `instance` (spec 3.0.0): the model is one this link's instance serves, as the model event says.
_meta: { lembas: { model: session.app.engine.model.ref, mode: session.app.engine.mode, effort: session.app.engine.effort ?? "off", instance: this.isInstance(session.app.engine.model.connectionName) } },
}
}
/** The ref of the model the instance names (`lembas_model`), or the reason it cannot be run here:
* thrown as -32602 (harness/acp.md, *The chat's model, full stop*). */
private chatModel(served: unknown): string {
if (typeof served !== "string" || !served.trim()) throw new RpcError(INVALID_PARAMS, "lembas_model must be the instance's name of a model")
const ref = this.refFor(served)
if (!ref) throw new RpcError(INVALID_PARAMS, `${served.trim()} is the instance's model, and this device is not linked to an instance: it cannot run it`)
return ref
}
/** Why the instance's model cannot run in `app`, or undefined when it can: the device is not
* logged in to this link's instance, or that instance does not serve the model to it. */
private notServed(app: App, ref: string): string | undefined {
const name = this.instanceConnection!
const conn = app.loaded.connections[name]
const served = ref.slice(name.length + 1)
if (!conn) return `this device is not logged in to ${name}, the instance this link is to, so it cannot run its model ${served} (lembas login there)`
const m = app.engine.model
if (m.connectionName !== name) return `${served} is not a model of ${name} on this device`
if (Object.keys(conn.models).length && !conn.models[m.id]) return `${conn.webui?.name ?? name} does not serve ${served} to this device (it serves: ${Object.keys(conn.models).slice(0, 12).join(", ")})`
return undefined
}
/** Whether a model spoken to through `connection` is this link's instance's. */
isInstance(connection: string | undefined): boolean {
return Boolean(connection && this.instanceConnection && connection === this.instanceConnection)
}
/** A model the instance names, as a ref on its connection here; undefined when there is none.
* `<login>/<served>` resolves to the served model under its ref now (`deepseek/x` for the
* served `deepseek/x`; the instance's `<login>/<served>` where a connection of the user's own
* has the provider's name), and to the same model for a bare id from an instance that does not speak protocol 2. */
/** A model the instance names, as a ref on its connection here (`<login>/<served>`, which resolves
* to the served id, provider/refs.ts); undefined when this link has no instance. */
private refFor(served: unknown): string | undefined {
if (typeof served !== "string" || !served.trim() || !this.instanceConnection) return undefined
return `${this.instanceConnection}/${served.trim()}`
@@ -620,8 +663,14 @@ export class AcpAgent {
const t = this.terminalFor(p)
if (t) return t.client.request("hub/configure", { sessionId: t.sessionId, model: this.refFor(p?.model), effort: p?.effort }, 30_000)
const s = this.get(p)
const ref = this.refFor(p?.model)
if (ref && s.app.canonicalRef(ref) !== s.app.engine.model.ref) s.app.switchModel(ref)
const ref = p?.model !== undefined ? this.chatModel(p.model) : undefined
if (ref && s.app.canonicalRef(ref) !== s.app.engine.model.ref) {
const conn = s.app.loaded.connections[this.instanceConnection!]
const id = s.app.canonicalRef(ref)
if (!conn) throw new RpcError(INVALID_PARAMS, `this device is not logged in to ${this.instanceConnection}, so it cannot run ${p.model}`)
if (Object.keys(conn.models).length && !conn.models[id]) throw new RpcError(INVALID_PARAMS, `${conn.webui?.name ?? this.instanceConnection} does not serve ${p.model} to this device`)
s.app.switchModel(ref)
}
const effort = effortOf(p?.effort)
if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : effort
return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" }
@@ -660,6 +709,7 @@ export class AcpAgent {
session.app = createApp({ cwd: dir, ...(L ? { mode: L.maxMode } : {}), asker: this.asker(session), modelTitles: false, resuming: id })
session.app.resume(id)
markRemote(session.app.store, id)
session.app.setScope(id)
if (L) session.app.engine.mode = clampMode(session.app.engine.mode, L.maxMode)
this.sessions.set(id, session)
this.adopt(session)
@@ -686,6 +736,7 @@ export class AcpAgent {
private adopt(s: Session) {
s.off?.()
s.agent = this
this.equipShow(s)
s.off = s.app.bus.on((e) => {
if (s.agent !== this) return
this.forward(s, e)
@@ -694,6 +745,18 @@ export class AcpAgent {
})
}
/** The show tool's display, for a session this link holds: the client's viewer, reached with
* `_lembas/show`, where the client said it shows files; otherwise none, and show is not offered. */
private equipShow(s: Session) {
const ctx = s.app.engine.o.toolCtx
if (!this.shows) return void delete ctx.show
ctx.show = async (req, callId) => {
if (s.agent !== this || this.peer.isClosed) return { shown: false, reason: "the client is not connected" }
const r = (await this.peer.request("_lembas/show", { sessionId: s.id, toolCallId: callId ?? "", ...req }, 30_000)) as { shown?: unknown; reason?: unknown } | undefined
return r?.shown === true ? { shown: true } : { shown: false, reason: typeof r?.reason === "string" && r.reason ? r.reason : "the client did not show it" }
}
}
/** `available_commands_update`: the session's command set, when it changed (or always). */
private sendCommands(s: Session, always = false) {
if (!this.ready || this.peer.isClosed || s.agent !== this) return
+30 -16
View File
@@ -8,12 +8,14 @@
// account lost the permission) ends the link for good: retrying it would only knock on a closed door.
import { existsSync, readFileSync } from "node:fs"
import { hostname } from "node:os"
import { resolve as resolvePath } from "node:path"
import { expandHome } from "../config/paths.ts"
import { remoteBlock } from "../config/remote.ts"
import { refusedLocally } from "../project/local.ts"
import type { Mode } from "../config/schema.ts"
import { HARNESS_VERSION } from "../harness.ts"
import { instances, keyFile, rememberProtocols, type Instance } from "../lembas/login.ts"
import { discover, linkProtocol, spoken } from "../lembas/client.ts"
import { discover, linkProtocol, ProtocolError, spoken } from "../lembas/client.ts"
import { VERSION } from "../version.ts"
import { AcpAgent, type Limits } from "./agent.ts"
import type { Hub } from "./hub.ts"
@@ -143,10 +145,11 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
o.onStatus?.(s)
return s
}
const ca = instance.ca && existsSync(instance.ca) ? readFileSync(instance.ca, "utf8") : undefined
/** A 4400 to a hello that said 2: the protocols known were stale (or discovery said more than the
* link takes) — the next dial, at once, says 1; only a 4400 to that stops the link. */
let downgraded = false
// The CA the login was given, as a full path (an older login kept what was typed: relative, or
// with ~, which a service started elsewhere does not find). One that is gone is said, not skipped.
const caPath = instance.ca ? resolvePath(expandHome(instance.ca)) : undefined
const caGone = caPath && !existsSync(caPath) ? caPath : undefined
const loginCa = caPath && !caGone ? readFileSync(caPath, "utf8") : undefined
while (!o.signal?.aborted) {
attempts++
@@ -154,12 +157,28 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
// What the instance speaks now: asked at every dial, since it may have been updated
// since the login; what was said last when it cannot be asked.
let protocols = instances()[instance.connection]?.protocols ?? instance.protocols
// What the socket trusts is what discovery just got through with: the login's CA, or the
// system's bundle when the instance's certificate needs it (Bun's own roots do not hold a
// private CA). Why discovery failed is why the dial will, said in the status.
let ca = loginCa
let why: string | undefined
let older: string | undefined
try {
const { discovery } = await discover(instance.base_url, ca ? { ca: instance.ca } : undefined)
protocols = spoken(discovery)
const found = await discover(instance.base_url, loginCa ? { ca: caPath! } : undefined)
protocols = spoken(found.discovery)
rememberProtocols(instance.connection, protocols)
} catch {}
const protocol = downgraded ? 1 : linkProtocol(protocols)
if (found.tls?.ca && found.tls.ca !== caPath) ca = readFileSync(found.tls.ca, "utf8")
} catch (e) {
why = (e as Error).message
// A server that answers and speaks another protocol: no dial would get further.
if (e instanceof ProtocolError) older = why
}
if (older) {
clearInterval(freshen)
return status("stopped", older)
}
const unreachable = [caGone && `the CA file given at login, ${caGone}, is not there`, why ?? "could not reach the instance"].filter(Boolean).join("; ")
const protocol = linkProtocol(protocols)
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => {
let opened = false
let changed = false
@@ -201,8 +220,8 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
// 4401/4403: the instance's word that this token is done; 1008 policy, likewise.
if ([4401, 4403, 1008].includes(e.code)) return resolve({ refused: e.reason || "the instance refused this device's token" })
// 4400: the hello's protocol is not one it speaks. Dialling again would say the same.
if (e.code === 4400) return resolve({ refused: "the instance and this CLI speak different link protocols — update one of them", final: true })
resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : "could not reach the instance" })
if (e.code === 4400) return resolve({ refused: `the instance does not speak link protocol ${protocol}, which this LLeMbas CLI ${VERSION} speaks: it needs a LLeMbas 2.0 server — update the server, or use LLeMbas CLI 1.x with it${e.reason ? ` (${e.reason})` : ""}`, final: true })
resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : unreachable })
})
ws.addEventListener("error", () => {})
})
@@ -212,11 +231,6 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
wait = first
continue
}
if (outcome.final && protocol === 2 && !downgraded) {
downgraded = true
status("waiting", "the instance does not take link protocol 2; trying again with 1")
continue
}
if (outcome.refused) {
clearInterval(freshen)
return status("stopped", outcome.final ? outcome.refused : `${outcome.refused} — sign in again: lembas login`)
+110 -67
View File
@@ -17,8 +17,8 @@ import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
import { hardlineRules } from "./permission/hardline.ts"
import { defaultEffort } from "./provider/effort.ts"
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
import { discoverContext, unload } from "./provider/discover.ts"
import { assembleSystem, instructionFiles, knowledgeScope } from "./prompt/assemble.ts"
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
import type { Changing } from "./session/prefix.ts"
import { describeRepo, repoState } from "./git/repo.ts"
import { findProject, persistProjectRule, trustOf, type Project } from "./project/root.ts"
import type { Message } from "./provider/types.ts"
@@ -42,7 +42,7 @@ import { Library, type Embedder } from "./library/store.ts"
import { instances as lembasInstances, keyFile as lembasKeyFile } from "./lembas/login.ts"
import { PERSONAL_KEYS, personalConfig, pushPersonalization } from "./lembas/personal.ts"
import type { Plan } from "./plan/plan.ts"
import { LIBRARY_SERVER } from "./mcp/index.ts"
import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
import type { McpServer } from "./config/schema.ts"
import { embedderFor } from "./library/embed.ts"
import { effortsFor } from "./provider/effort.ts"
@@ -84,9 +84,6 @@ export interface SessionContext {
instructions?: string
/** The account's personalization, in place of this device's; null: the device's own. */
personalization?: { personality: string; personality_custom: string; instructions: string } | null
/** The knowledge bases this session may search (named to the model, and the scope sent on
* every library call); an empty list: the account's whole library. */
library?: { bases: { id: string; name: string }[] }
/** Whether the model may write a prototype, and what a page may load. */
prototypes?: { offered: boolean; sources: string }
}
@@ -100,7 +97,7 @@ export interface App {
/** The subagents the task tool offers (built-in, global, and a trusted project's), as of start. */
agents: import("./project/agents.ts").AgentSpec[]
store: Store | undefined
/** Every configured `connection/model`, in config order. */
/** Every model the server serves, by its id there, in its order. */
modelRefs(): string[]
/** A ref in any form it may be written in (the old `<login>/<model>` too) as the ref the
* model has now; the ref itself when it names nothing. */
@@ -152,27 +149,36 @@ export interface App {
context: SessionContext
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
setContext(ctx: SessionContext, from?: string): void
/** The chat a session the client opened belongs to (its id): sent with every call to the server's
* memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a
* session of this device's own. The memory for the prompt is read again in it. */
setScope(scope: string | undefined): void
}
/** This machine's library tools, which `library: lembas` replaces with the instance's. */
const LOCAL_LIBRARY = new Set(["memory", "notes_search", "note_view", "note_manage", "knowledge_search", "knowledge_get", "skills_list", "skill_view", "skill_manage"])
/** What the LLeMbas server's /mcp stands in for (harness spec 3.0.0, acp.md *Memory*): memory
* alone. Notes, knowledge bases and skills stay this machine's. */
const SERVER_MEMORY = new Set(["memory"])
/** The resource holding the server's memory entries as the prompt's memory block shows them. */
export const MEMORY_RESOURCE = "lembas://memory"
function lembasLibraryServer(warnings: string[]): (McpServer & { source: "global" }) | undefined {
const all = Object.values(lembasInstances())
const keyMissing = all.length === 1 && !existsSync(lembasKeyFile(all[0]!.connection))
if (all.length !== 1 || keyMissing) {
if (keyMissing) warnings.push("library: lembas, but the login's key file is gone (lembas login again); using this machine's library")
else
warnings.push(all.length ? "library: lembas needs exactly one instance logged in to; using this machine's library" : "library: lembas, but not logged in to any instance (lembas login); using this machine's library")
const one = all.length > 1 ? [...all].sort((a, b) => b.logged_in_at.localeCompare(a.logged_in_at))[0] : all[0]
if (!one || !existsSync(lembasKeyFile(one.connection))) {
warnings.push(one ? "library: lembas, but the login's key file is gone (lembas login again); memory is this machine's for now" : "library: lembas, but not logged in (lembas login); memory is this machine's for now")
return undefined
}
const one = all[0]!
return {
url: `${one.base_url}/mcp`,
// The token itself, read now: the config's {file:} substitution has already run by here.
headers: { authorization: `Bearer ${readFileSync(lembasKeyFile(one.connection), "utf8").trim()}` },
transport: "http",
oauth: false,
// The memory tool alone; the resource is read for the prompt, not offered to the model.
tools: { include: [...SERVER_MEMORY] },
prompts: false,
resources: false,
instructions: false,
...(one.ca ? { tls: { ca: one.ca } } : {}),
source: "global",
}
@@ -248,8 +254,9 @@ export function createApp(o: AppOptions): App {
return s ? describeRepo(s) : undefined
}
// Memory and skills go into the system prompt as they stood when the session started: a write
// during the session reaches the file, not the prompt (Hermes' frozen snapshot).
// Memory and skills go into the system prompt as they stood when it was made: a write during the
// session reaches the file, not the prompt (Hermes' frozen snapshot) — the person's own edit
// reaches the model as a note (session/prefix.ts).
const memoryOn = loaded.config.memory?.enabled !== false
const memory = new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: loaded.config.memory?.user_chars ?? DEFAULT_LIMITS.user })
const projectDir = () => (trusted && existsSync(project.dir) ? project.dir : undefined)
@@ -258,16 +265,22 @@ export function createApp(o: AppOptions): App {
? new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: 0 }, join(project.dir, "local"), { memory: "PROJECT MEMORY (this project only)" })
: undefined
const skillList = () => loadSkills({ projectDir: projectDir(), external: loaded.config.skills?.external_dirs, disabled: loaded.config.skills?.disabled })
// Everything in the system prompt that could change during a session is taken once, here, for
// the same reason as the git block: the prompt must stay byte for byte the same, or the server
// reads the whole conversation again.
// What the system prompt holds that nothing during the session updates: taken as the session
// starts, for the same reason as the git block. The engine makes the prompt once and resends it
// byte for byte (harness context.md §10); what may change and is said in a note when it does —
// memory, instruction files, personality, MCP instructions — is read by `changing` below.
const freeze = () => ({
memory: memoryOn ? [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n") : undefined,
skills: skillLines(skillList()),
git: gitSummary(),
tasks: projectDir() && !unsafeProjectFile(join(project.dir, "tasks.md"), project.root) ? boardSummary(project.dir) : undefined,
files: instructionFiles(o.cwd, project.root, loaded.instructions),
})
/** The memory block as it is now: the server's when it is the server's (as last read for this
* session, readServerMemory), else this machine's files; undefined when memory is off. */
const memoryNow = () => (!memoryOn ? undefined : serverMemory !== undefined ? serverMemory.text : [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n"))
// `library: lembas`: the server keeps the memory (acp.md *Memory*); its entries for the prompt are
// read from it as a session starts. Until the read answers, none.
const memoryOnServer = loaded.config.library === "lembas" && memoryOn
let serverMemory: { text: string } | undefined = memoryOnServer ? { text: "" } : undefined
let frozen = freeze()
// The library: one file for every project; an embedding model only when one is set (and usable).
@@ -284,9 +297,12 @@ export function createApp(o: AppOptions): App {
let contextFrom = "the instance"
// library: lembas — the account's library through the instance's /mcp, in place of this
// machine's. Needs the one instance logged in to, with a token that has the library scope.
const lembasLibrary = loaded.config.library === "lembas" ? lembasLibraryServer(loaded.warnings) : undefined
// machine's. Needs the one instance logged in to, with a token that has the memory scope (or
// the older library).
const lembasLibrary = memoryOnServer ? lembasLibraryServer(loaded.warnings) : undefined
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
// Without a way to the server's memory, this machine's is used for now.
if (memoryOnServer && !lembasLibrary) serverMemory = undefined
const mcp = new McpManager(loaded.mcp, {
root: project.root,
version: VERSION,
@@ -318,21 +334,38 @@ export function createApp(o: AppOptions): App {
sessions: store ? { store, current: () => engine.sessionId } : undefined,
settings: { get: (k: string) => settings.get(k), list: () => settings.list(), set: (k: string, v: unknown, sc?: "session" | "global" | "project") => settings.set(k, v, sc), models: () => modelRefs() },
})
/** Whether knowledge_search is offered (engine.offered's rule): a base with documents in scope,
* or the instance's library standing in for this machine's. */
const knowledgeOffered = () => Boolean(lembasLibrary) || library.bases().some((b) => b.documents > 0 && (!libraryCtx.bases || libraryCtx.bases.includes(b.name)))
/** The library block's scope: the client's bases over the link, else this machine's `knowledge:`. */
const libraryBlock = () => {
if (!knowledgeOffered()) return undefined
const names = lembasLibrary ? context.library?.bases.map((b) => b.name) : libraryCtx.bases
return { scope: knowledgeScope(names) }
}
/** Whether knowledge_search is offered (engine.offered's rule): a base of this machine's with
* documents in scope. Knowledge bases are this machine's alone (spec 3.0.0). */
const knowledgeOffered = () => library.bases().some((b) => b.documents > 0 && (!libraryCtx.bases || libraryCtx.bases.includes(b.name)))
/** The library block's scope: this machine's `knowledge:`. */
const libraryBlock = () => (knowledgeOffered() ? { scope: knowledgeScope(libraryCtx.bases) } : undefined)
/** Prototypes: offered by the client for this session, or by `prototypes: true` for a terminal's. */
const prototypesBlock = () => (context.prototypes?.offered || (!context.prototypes && loaded.config.prototypes === true) ? { sources: context.prototypes?.sources ?? "" } : undefined)
/** The three personal keys this session uses: the client's for it, else the config's. */
const personal = () => (context.personalization ? personalConfig({ enabled: true, available: true, ...context.personalization }) : loaded.config)
const system = (m: Mode, current: ResolvedModel, subagent?: { name: string; instructions: string; report: boolean; skills: boolean }, at?: { cwd: string; root: string }, plan?: Plan) =>
assembleSystem({
/** What may change during the session, as it is now (session/prefix.ts `Changing`): what the
* prompt is made from, and what a note compares. A subagent's prompt holds no memory and no
* personality; a worktree subagent reads its own checkout's instruction files. */
type Source = { files: { path: string; text: string; chain?: true }[]; memory?: string; personality?: string; userInstructions?: string; instance?: { name: string; instructions: string }; mcp: Record<string, string> }
const changing = (subagent?: boolean, at?: { cwd: string; root: string }): Changing => {
const files = instructionFiles(at?.cwd ?? o.cwd, at?.root ?? project.root, loaded.instructions)
const p = subagent ? {} : personalityOf(personal())
const src: Source = {
files,
memory: subagent ? undefined : memoryNow(),
...p,
...(!subagent && context.instructions ? { instance: { name: contextFrom, instructions: context.instructions } } : {}),
mcp: mcp.serverInstructions(),
}
const named: Record<string, string> = Object.fromEntries(files.map((f) => [f.path, f.text]))
// What a note names the person's own instructions by (loop.json context.note.personal).
if (src.userInstructions) named[LOOP.context.note.personal] = src.userInstructions
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src }
}
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan) => {
const src = ch.source as Source
return assembleSystem({
modelRef: current.ref,
family: familyOf(current),
cwd: at?.cwd ?? o.cwd,
@@ -346,21 +379,26 @@ export function createApp(o: AppOptions): App {
// A worktree subagent works elsewhere: its own snapshot, taken as it starts.
git: at ? gitSummary() : frozen.git,
tasks: frozen.tasks,
files: at ? undefined : frozen.files,
files: src.files,
subagent,
memory: frozen.memory,
memory: src.memory,
// The skills block tells of skill_view; a subagent without the tool gets no block.
skills: subagent && !subagent.skills ? undefined : frozen.skills,
manageSkills: true,
// Read on every prompt: /personality and /settings change it without a restart.
...personalityOf(personal()),
mcp: mcp.instructions(),
// As the prompt is made; /personality and /settings later change it by a note.
personality: src.personality,
userInstructions: src.userInstructions,
mcp: mcpInstructions(src.mcp),
roster: roster(current.ref),
plan,
...(context.instructions ? { instance: { name: contextFrom, instructions: context.instructions } } : {}),
...(src.instance ? { instance: src.instance } : {}),
library: libraryBlock(),
prototypes: prototypesBlock(),
context: subagent ? subagent.recall : true,
})
}
/** A mode's text for a note, as the prompt would have it. */
const modeNote = (m: Mode) => modeText(m, planDir, o.unattended ? UNATTENDED[m] : undefined)
// The other configured models, for the task tool's `model`, or a switch.
const roster = (current: string) =>
loaded.refs
@@ -369,14 +407,14 @@ export function createApp(o: AppOptions): App {
.slice(0, 15)
.map((m) => `- ${m.ref}${m.spec.name ? ` (${m.spec.name})` : ""}${m.spec.notes ? ` — ${m.spec.notes}` : ""}`)
.join("\n")
const compaction = { autoAt: loaded.config.compaction?.auto_at, prune: loaded.config.compaction?.prune }
const compaction = { autoAt: loaded.config.compaction?.auto_at }
// Subagents (the task tool): a child engine with its own bus and read-tracker, the agent's
// tools and mode, the same asker (permission prompts still reach the user), no task tool of its
// own. Only its final reply goes back; its tool lines are shown under the task line.
const agentList = agents(trusted ? project.dir : undefined, loaded.warnings)
const offered = BUILTIN_TOOLS.filter(
(t) => (memoryOn || t.name !== "memory") && (settingsTool !== "off" || t.name !== "settings") && !(lembasLibrary && LOCAL_LIBRARY.has(t.name)) && !SUBAGENT_ONLY.has(t.name),
(t) => (memoryOn || t.name !== "memory") && (settingsTool !== "off" || t.name !== "settings") && !(lembasLibrary && SERVER_MEMORY.has(t.name)) && !SUBAGENT_ONLY.has(t.name),
)
// The spec's descriptions carry the facts LLeMbas CLI fills in itself (harness/README.md).
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
@@ -496,12 +534,14 @@ export function createApp(o: AppOptions): App {
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
},
fallback: engine.o.fallback,
steerMark: MARKS.to_subagent,
system: (mode, current, plan) =>
changing: () => changing(true, at),
modeText: modeNote,
system: (mode, current, plan, ch) =>
system(
mode,
current,
ch,
{
name: spec.name,
instructions: wt
@@ -509,6 +549,7 @@ export function createApp(o: AppOptions): App {
: spec.instructions,
report: childTools.some((t) => t.name === "report"),
skills: childTools.some((t) => t.name === "skill_view"),
recall: childTools.some((t) => t.name === "recall"),
},
at,
plan,
@@ -562,19 +603,13 @@ export function createApp(o: AppOptions): App {
compaction,
perm,
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } },
system: (m, current, plan) => system(m, current, undefined, undefined, plan),
system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
changing: () => changing(),
modeText: modeNote,
// The configured MCP servers, up to loop.json's wait: their tools in the first list.
beforeFirst: () => Promise.race([mcpReady.catch(() => {}), Bun.sleep(LOOP.context.mcp_wait_ms)]),
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
spawn,
fallback(current, tried) {
for (const ref of current.spec.fallback ?? []) {
if (tried.has(ref)) continue
try {
const m = resolveModel(loaded, ref)
return { model: m, client: clientFor(m), effort: startEffort(m) }
} catch {}
}
return undefined
},
})
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
@@ -596,6 +631,19 @@ export function createApp(o: AppOptions): App {
}
})
const mcpReady = mcp.start()
/** The server's memory entries for the prompt (`lembas://memory`), read as a session starts, in
* the session's scope: in the prompt when it is made after this, else said in a note on the next
* user message — the prompt does not change under a session. */
const readServerMemory = async () => {
if (!lembasLibrary || !serverMemory) return
try {
const text = await mcp.readResource(LIBRARY_SERVER, MEMORY_RESOURCE, engine.o.toolCtx.libraryScope)
serverMemory = { text }
} catch (e) {
bus.emit({ type: "notice", level: "warn", message: `memory: the server's entries could not be read (${(e as Error).message}); none are in this session's prompt` })
}
}
void mcpReady.then(readServerMemory)
// small_model when it resolves; a broken one is said once and the session's model used instead.
let smallWarned = false
@@ -645,18 +693,11 @@ export function createApp(o: AppOptions): App {
const snapshots = o.snapshots === false ? undefined : new Snapshots(project.root, project.gitRoot)
engine.busyInput = loaded.config.busy_input ?? "steer"
const turns = new Turns(engine, snapshots, store)
// After a reply, learn the window if nobody configured it (the model is loaded by then).
bus.on((e) => {
if (e.type === "done" && !engine.model.spec.context) void discoverContext(engine.model, engine.client).then((n) => n && engine.usage && bus.emit({ type: "usage", usage: engine.usage, context: n, used: engine.contextUsed() }))
})
// By provider for an instance's models (`deepseek/deepseek-flash`), src/provider/refs.ts.
const modelRefs = () => loaded.refs.map((r) => r.ref)
const switchModel = (ref: string) => {
const m = resolveModel(loaded, ref)
const prev = engine.model
engine.setModel(m, clientFor(m), startEffort(m))
// Moving to another connection frees the old one's memory (llama-swap /unload).
if (prev.connectionName !== m.connectionName) void unload(prev)
}
const setIn = (key: string, value: unknown) => {
const path = key.split(".")
@@ -699,7 +740,6 @@ export function createApp(o: AppOptions): App {
else if (key === "limits.bash_timeout") engine.o.toolCtx.bashTimeoutMs = Number(v) * 1000
else if (key === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
else if (key === "compaction.prune") compaction.prune = Boolean(v)
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
bus.emit({ type: "setting", key, value: v })
@@ -772,9 +812,10 @@ export function createApp(o: AppOptions): App {
setContext(ctx, from) {
context = ctx
if (from) contextFrom = from
// The instance library answers within the chat's scope: the session's id rides on every
// call to it while the client has said what the session may search.
engine.o.toolCtx.libraryScope = ctx.library ? engine.sessionId : undefined
},
setScope(scope) {
engine.o.toolCtx.libraryScope = scope
void readServerMemory()
},
rename(title) {
const id = engine.sessionId
@@ -784,6 +825,7 @@ export function createApp(o: AppOptions): App {
},
newSession() {
frozen = freeze()
void readServerMemory()
named = "no"
firstShown = ""
const s = store?.createSession(project.root, engine.model.ref)
@@ -801,6 +843,7 @@ export function createApp(o: AppOptions): App {
if (!store) return []
const before = engine.sessionId
frozen = freeze()
void readServerMemory()
engine.resume(id, store.context(id))
// A session from before titles gets its first prompt's line now, and the model's after the next reply.
firstShown = ""
+38 -25
View File
@@ -12,7 +12,7 @@ import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.t
import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts"
import { findProject, setTrust, trustOf } from "./project/root.ts"
import { effortsFor } from "./provider/effort.ts"
import { clientFor, ModelError, resolveModel } from "./provider/index.ts"
import { clientFor, ModelError, NOT_LOGGED_IN, resolveModel } from "./provider/index.ts"
import { Store } from "./session/store.ts"
import { promptTitle } from "./session/title.ts"
import { VERSION } from "./version.ts"
@@ -23,7 +23,7 @@ Usage:
lembas [-l] [-m model] [--mode M] [-c | -s id]
the TUI (-c continues the last session here, -s resumes one)
lembas run [options] "…" one prompt, headless
lembas models [--discover] list configured models
lembas models list the server's models
lembas sessions list recent sessions
lembas sessions delete <id>… delete sessions, here and in the web UI
lembas sessions prune [--dry-run]
@@ -38,6 +38,9 @@ Usage:
sign in to a LLeMbas instance: its models, voice and search
(again: read them now; --default pins its default model;
--json: one JSON object per line, for a program to read)
… | lembas login --token-stdin --server <url> [--ca file] [--json]
sign in with a token a program already holds (lmb_…, read
from stdin only), checked against the server first
lembas logout [connection] sign out of one, and remove what login wrote
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
lembas service install | uninstall | status | logs [-f] | run
@@ -71,7 +74,7 @@ Local mode:
it. Your logins and connections.yaml are read, never written; your
config.yaml is not read (the local one is used; lembas -l config set
… writes it). A LLeMbas instance is used only for its models, chat,
speech in and out (/v1/audio) and the balance: no link, /remote or
speech in and out (/v1/audio) and the usage: no link, /remote or
service, no sessions or deletes sent to it, no server library,
search, fetch or embeddings, no account personalization, and no
self-update. The instance still records the usage of each request,
@@ -95,27 +98,51 @@ function fail(msg: string, code = 1): never {
}
async function loginCommand(cmd: "login" | "logout", args: string[]) {
const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, logout, sync, LembasError } = await import("./lembas/login.ts")
const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, loginWithToken, logout, sync, LembasError } = await import("./lembas/login.ts")
const { values, positionals } = parseArgs({
args,
allowPositionals: true,
options: { ca: { type: "string" }, default: { type: "boolean" }, "keep-services": { type: "boolean" }, json: { type: "boolean" } },
options: {
ca: { type: "string" },
default: { type: "boolean" },
"keep-services": { type: "boolean" },
json: { type: "boolean" },
// The token only ever from stdin: an argument or a variable would show in a process list.
"token-stdin": { type: "boolean" },
server: { type: "string" },
},
})
const known = instances()
const json = cmd === "login" && values.json
const emit = (o: object) => console.log(JSON.stringify(o))
const sameBase = (a: string, b: string) => a.replace(/^https?:\/\//, "").replace(/\/+$/, "") === b.replace(/^https?:\/\//, "").replace(/\/+$/, "")
if (cmd === "login" && (values["token-stdin"] || values.server !== undefined)) {
const usage = "usage: … | lembas login --token-stdin --server <url> [--json] [--ca file]"
if (!values["token-stdin"]) fail(`--server goes with --token-stdin (${usage}); a sign-in by code is lembas login <address>`, 2)
if (!values.server) fail(usage, 2)
if (positionals.length) fail(`--token-stdin takes the address as --server <url> (${usage})`, 2)
if (process.stdin.isTTY) fail(`--token-stdin reads the token from a pipe, never from the terminal or an argument (${usage})`, 2)
}
try {
if (cmd === "login" && values["token-stdin"]) {
const token = (await Bun.stdin.text()).slice(0, 65536)
const before = Object.values(known).find((i) => sameBase(i.base_url, values.server!))
const r = await loginWithToken(values.server!, token, { ca: values.ca ?? before?.ca, setDefault: values.default, keepServices: values["keep-services"] })
if (json) emit(doneEvent(r, true))
else console.log(loginSummary(r, true).join("\n"))
return
}
if (cmd === "logout") {
const name = positionals[0] ?? (Object.keys(known).length === 1 ? Object.keys(known)[0]! : "")
if (!name) fail(Object.keys(known).length ? `which one? lembas logout <${Object.keys(known).join("|")}>` : "not logged in to any instance", 2)
const r = await logout(name)
console.log(`Signed out of ${name}${r.revoked ? "" : " here (the instance could not be told — revoke it under User → Security → Devices there)"}; its connection is removed.`)
if (r.modelLeft) console.log(`config.yaml still starts on ${r.modelLeft}: choose another with lembas config set model <connection/model>.`)
if (r.modelLeft) console.log(`config.yaml still starts on ${r.modelLeft}: choose another with lembas config set model <id>.`)
return
}
let address = positionals[0] ?? ""
// `login` again with no address refreshes the one instance there is.
const again = address ? Object.values(known).find((i) => i.base_url.replace(/^https?:\/\//, "") === address.replace(/^https?:\/\//, "").replace(/\/+$/, "")) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined
const again = address ? Object.values(known).find((i) => sameBase(i.base_url, address)) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined
if (again && !values.ca && isWebuiEntry(again.connection)) {
const r = await sync(again.connection, { setDefault: values.default })
if (json) emit(doneEvent(r, false))
@@ -305,17 +332,11 @@ async function main() {
if (cmd === "models") {
const loaded = loadConfig()
const discover = argv.includes("--discover")
if (!Object.keys(loaded.connections).length && !Object.keys(loaded.broken).length) console.log(NOT_LOGGED_IN)
for (const [name, c] of Object.entries(loaded.connections)) {
console.log(
c.webui
? `${name} (webui, ${c.webui.url}${c.webui.name ? ` — ${c.webui.name}` : ""}; models ${c.webui.fetched_at ? `read ${c.webui.fetched_at.slice(0, 16).replace("T", " ")}` : "not read yet"})`
: `${name} (${c.dialect}, ${c.base_url})`,
)
console.log(`${name} (${c.webui?.url ?? c.base_url}${c.webui?.name ? ` — ${c.webui.name}` : ""}; models ${c.webui?.fetched_at ? `read ${c.webui.fetched_at.slice(0, 16).replace("T", " ")}` : "not read yet"})`)
for (const [id, spec] of Object.entries(c.models)) {
// Its ref: an instance's model by provider, anything else connection/model.
const ref = loaded.refs.find((r) => r.connection === name && r.id === id)?.ref ?? `${name}/${id}`
const m = resolveModel(loaded, ref)
const m = resolveModel(loaded, id)
const bits = [
spec.context ? `ctx ${spec.context}` : "",
spec.max_output ? `out ${spec.max_output}` : "",
@@ -323,15 +344,7 @@ async function main() {
spec.tools === false ? "no-tools" : "",
spec.effort || spec.efforts ? `effort ${spec.effort ?? "off"} of ${effortsFor(m).join("/")}` : "",
].filter(Boolean)
console.log(` ${ref}${bits.length ? " — " + bits.join(", ") : ""}`)
}
if (discover) {
try {
const found = await clientFor(resolveModel(loaded, `${name}/${Object.keys(c.models)[0] ?? "?"}`)).listModels()
for (const d of found) if (!c.models[d.id]) console.log(` ${name}/${d.id} (discovered${d.context ? `, ctx ${d.context}` : ""})`)
} catch (e) {
console.log(` (discovery failed: ${(e as Error).message})`)
}
console.log(` ${m.ref}${bits.length ? " — " + bits.join(", ") : ""}`)
}
}
for (const [name, why] of Object.entries(loaded.broken)) console.log(`${name} ✗ ${why}`)
+1 -1
View File
@@ -29,7 +29,7 @@ export function jsonSchemas(): Record<keyof typeof SCHEMA_FILES, Record<string,
})
return {
config: make(Config, "LLeMbas CLI config.yaml", "~/.config/lembas/config.yaml, or a project's .agent/config.yaml. Strings may be {env:NAME} or {file:path}."),
connections: make(ConnectionsFile, "LLeMbas CLI connections.yaml", "~/.config/lembas/connections.yaml: the LLM endpoints. Global only. Keys as {env:NAME}, {file:path} or key_cmd — never pasted."),
connections: make(ConnectionsFile, "LLeMbas CLI connections.yaml", "~/.config/lembas/connections.yaml: the LLeMbas server this machine is logged in to, as lembas login writes it. Global only."),
}
}
+46 -37
View File
@@ -3,8 +3,8 @@ import { join } from "node:path"
import { isScalar, parse, parseDocument } from "yaml"
import { z } from "zod"
import { connectionFiles, isLocal, paths } from "./paths.ts"
import { asMode, Config, Connection, ConnectionsFile, McpServer, PERSONALITIES, renamePermissionKeys, WebuiConnection, type PermissionConfig } from "./schema.ts"
import { runKeyCmd, substituteDeep } from "./substitute.ts"
import { asMode, Config, McpServer, PERSONALITIES, renamePermissionKeys, WebuiConnection, type Connection, type PermissionConfig } from "./schema.ts"
import { substituteDeep } from "./substitute.ts"
import { expandWebui, refreshFailures, type WebuiMark } from "../lembas/webui.ts"
import { defaultLibrary, withInstancePersonalization } from "../lembas/personal.ts"
import { instances } from "../lembas/login.ts"
@@ -14,8 +14,8 @@ export class ConfigError extends Error {}
export interface Loaded {
config: Config
/** A `type: webui` entry is here as the openai-chat connection it is spoken to as, with the
* instance's models and a `webui` mark. */
/** The LLeMbas server this machine is logged in to — at most one: its `webui` entry as the
* `/v1` it is spoken to at, with its models and a `webui` mark (custom connections are gone). */
connections: Record<string, Connection & { webui?: WebuiMark }>
/** Connections whose {env:}/{file:} substitution failed, with the reason. Unusable, not fatal. */
broken: Record<string, string>
@@ -121,6 +121,14 @@ function legacyKeys(all: Record<string, unknown>, file: string, notes: string[])
if (!(typeof p === "string" && ["none", "default", "neutral"].includes(p.toLowerCase())))
notes.push(`${file}: personality "${String(p)}" is not a preset any more (${PERSONALITIES.join(", ")}); none is used`)
}
// compaction.prune cut old tool outputs in place, which threw the prompt cache away; harness spec
// 3.0.0 never rewrites what was sent (context.md), so the key is gone.
const comp = all.compaction
if (comp && typeof comp === "object" && !Array.isArray(comp) && "prune" in comp) {
const { prune: _, ...rest } = comp as Record<string, unknown>
all.compaction = rest
notes.push(`${file}: compaction.prune was removed — old tool outputs are no longer cut afterwards; delete it`)
}
// One file written as a plain string, not a list: read as the list of that one, not dropped
// without a word by the schema.
if (typeof all.instruction_files === "string") {
@@ -262,41 +270,47 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
const connections: Loaded["connections"] = {}
const broken: Record<string, string> = {}
// Validate each raw file, then substitute each connection on its own: a missing variable for one
// connection must not stop the others from working. In local mode, the user's own file first
// (read, never written), then the local one, whose entries win by name.
// The login: a `webui` entry, as `lembas login` writes it. An entry of any other kind is a custom
// connection from 1.x, gone in 2.0 (the CLI is LLeMbas-server-specific): said, and read past, so
// an old file still starts. In local mode, the user's own file first (read, never written), then
// the local one, whose entries win by name. Each entry is substituted on its own.
const raw: Record<string, unknown> = {}
for (const connFile of connectionFiles()) {
files.push(connFile)
const mode = statSync(connFile).mode & 0o777
if (mode & 0o077) warnings.push(`${connFile} is mode ${mode.toString(8)}; chmod 600 it — it references credentials`)
Object.assign(raw, validate(ConnectionsFile, readYaml(connFile), connFile).connections)
const doc = readYaml(connFile)
const entries = doc && typeof doc === "object" ? (doc as { connections?: unknown }).connections : undefined
if (entries === undefined || entries === null) continue
if (typeof entries !== "object" || Array.isArray(entries)) throw new ConfigError(`${connFile} is invalid:\n connections: expected the login as written by lembas login`)
for (const [name, c] of Object.entries(entries as Record<string, unknown>)) {
if ((c as { type?: string } | null)?.type === "webui") raw[name] = c
else warnings.push(`${connFile}: ${name} is a custom connection, and LLeMbas CLI 2.0 has none — it talks only to the LLeMbas server it is logged in to (lembas login <address>). Ignored; delete it.`)
}
}
for (const [name, c] of Object.entries(raw)) {
// One server: the login made last, where several are left from 1.x.
const logins = instances()
const names = Object.keys(raw)
const chosen = names.length > 1 ? [...names].sort((a, b) => (logins[b]?.logged_in_at ?? "").localeCompare(logins[a]?.logged_in_at ?? ""))[0]! : names[0]
if (names.length > 1) warnings.push(`connections.yaml holds ${names.length} LLeMbas logins (${names.join(", ")}); LLeMbas CLI 2.0 uses one server — ${chosen}, the latest. lembas logout the others.`)
if (chosen) {
const name = chosen
try {
if ((c as { type?: string }).type === "webui") {
const w = WebuiConnection.safeParse(substituteDeep(c, `connections.${name}`))
if (w.success) {
const c = (connections[name] = expandWebui(name, w.data))
if (refreshFailures[name])
warnings.push(
`${name}: the instance could not be asked for its models (${refreshFailures[name]}); ${c.webui.fetched_at ? `using what it said at ${c.webui.fetched_at.slice(0, 16).replace("T", " ")}` : "none known yet"}`,
)
}
else broken[name] = `once {env:}/{file:} are filled in: ${w.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
continue
}
// Again once filled in: "{env:LLM_URL}" passed as a placeholder; what it became must be a URL.
const r = Connection.safeParse(substituteDeep(c, `connections.${name}`))
if (r.success) connections[name] = r.data
else broken[name] = `once {env:}/{file:} are filled in: ${r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
const w = WebuiConnection.safeParse(substituteDeep(raw[name], `connections.${name}`))
if (w.success) {
const c = (connections[name] = expandWebui(name, w.data))
if (refreshFailures[name])
warnings.push(
`${name}: the instance could not be asked for its models (${refreshFailures[name]}); ${c.webui.fetched_at ? `using what it said at ${c.webui.fetched_at.slice(0, 16).replace("T", " ")}` : "none known yet"}`,
)
} else broken[name] = `once {env:}/{file:} are filled in: ${w.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
} catch (e) {
broken[name] = (e as Error).message
}
}
/** The webui connection a voice half or the search means: the one named, or the only one. A
* string says why there is none. */
/** The server a voice half or the search goes through: the login (`connection:` may still name
* it). A string says why there is none. */
const webuiFor = (named?: string): { name: string; c: Loaded["connections"][string] } | string => {
if (named) return connections[named]?.webui ? { name: named, c: connections[named]! } : broken[named] ? `${named} is unusable: ${broken[named]}` : `there is no webui connection named ${named}`
const all = Object.entries(connections).filter(([, c]) => c.webui)
@@ -389,7 +403,7 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
config = { ...config, search: sr }
}
// Local mode: an instance serves models, chat, speech and the balance, and nothing that is kept
// Local mode: an instance serves models, chat, speech and the usage, and nothing that is kept
// or shown there — no search or fetch through it, no embeddings, no library, no personalization
// (the local keys apply) — and the whole hardline stays.
if (isLocal()) config = localOnly(config, connections, warnings)
@@ -400,10 +414,7 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
config = personal.config
if (config.library === undefined && !isLocal() && defaultLibrary()) config = { ...config, library: "lembas" }
// An instance that speaks protocol 2 serves `<provider>/<model>` ids.
const logins = instances()
for (const [name, c] of Object.entries(connections)) if (c.webui && logins[name]?.protocols?.includes(2)) c.webui.v2 = true
const refs = modelRefs(connections, Object.keys(logins))
const refs = modelRefs(connections)
// A config.yaml naming the model the way it was named before providers (`example/deepseek-flash`)
// is rewritten to the new name, once (migrateRefs). Only that form: a pinned `<login>/<provider>/
// <model>` means what it says, and `embedding` stays as written — its name keys the stored vectors.
@@ -472,9 +483,7 @@ function localOnly(config: Config, connections: Loaded["connections"], warnings:
return out
}
/** The API key for a connection: `api_key` (already substituted) or the output of `key_cmd`. */
export function resolveKey(name: string, c: Connection): string | undefined {
if (c.api_key) return c.api_key
if (c.key_cmd) return runKeyCmd(c.key_cmd, `connections.${name}.key_cmd`)
return undefined
/** The server's token: the login's `api_key`, already substituted from its key file. */
export function resolveKey(_name: string, c: Pick<Connection, "api_key">): string | undefined {
return c.api_key || undefined
}
+41 -42
View File
@@ -1,6 +1,5 @@
import { z } from "zod"
export const DIALECTS = ["openai-chat", "responses", "anthropic", "gemini", "ollama"] as const
export const EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const
export const MODES = ["manual", "edit", "auto", "plan"] as const
/** The personality presets (harness/prompts/personality/, shared with the web UI), then custom. */
@@ -54,46 +53,44 @@ export const ModelSpec = z.strictObject({
top_p: z.number().min(0).max(1).optional().describe("Nucleus sampling; absent = the server's default."),
efforts: z.array(z.enum(EFFORTS)).optional().describe("The model's own effort vocabulary, in offering order."),
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("Default effort; `off` sends none."),
effort_style: z.enum(["top", "kwargs", "both"]).optional().describe("openai-chat only: where the effort goes. llama.cpp drops top-level, so `both` is the default."),
effort_map: z.partialRecord(z.enum(EFFORTS), z.number().int().positive()).optional().describe("anthropic/gemini: effort → thinking budget tokens."),
effort_style: z.enum(["top", "kwargs", "both"]).optional().describe("Where the effort goes in a request: top-level, in chat_template_kwargs, or both (the default: llama.cpp drops top-level)."),
vision: z.boolean().optional().describe("The model reads images: @image files, pasted paths and view_image."),
tools: z.boolean().optional().describe("false: a model without tool calls — it can only talk."),
cache: z.boolean().optional().describe("anthropic: prompt caching breakpoints."),
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
fallback: z.array(z.string()).optional().describe("Other models (connection/model), in order, to switch to when this one's server cannot be reached at all — never once a reply has begun."),
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused."),
group: z.string().optional().describe("Models sharing one server that holds one model at a time (llama-swap), named by any string: a subagent on another model of the same group would unload this one, so it is refused. One connection's one_model_at_a_time is the same rule for all its models; this is for models one connection serves from several such servers (a LLeMbas instance)."),
group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."),
})
export type ModelSpec = z.infer<typeof ModelSpec>
export const Connection = z.strictObject({
dialect: z.enum(DIALECTS).describe("The API it speaks: openai-chat (llama.cpp, vLLM, LM Studio, OpenAI…), responses (OpenAI Responses), anthropic, gemini, ollama (its native /api/chat)."),
base_url: Url.describe("Where the API is, e.g. https://api.openai.com/v1 or http://localhost:8080/v1."),
api_key: z.string().optional().describe("Never pasted: {env:NAME} or {file:~/path}. Or use key_cmd."),
key_cmd: z.string().optional().describe("A command whose output is the key, e.g. pass show openai."),
auth: z.enum(["bearer", "x-api-key", "x-goog-api-key", "none"]).optional().describe("How the key is sent. Default per dialect: anthropic x-api-key, gemini x-goog-api-key, others bearer."),
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for every request."),
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body."),
tls: Tls.optional().describe("TLS for endpoints behind a private CA. `ca` is a PEM file added to the trusted roots; `insecure` skips verification entirely (last resort). The system store is always trusted."),
timeout: z.number().positive().optional().describe("Seconds of silence allowed: waiting for the response, then between two pieces of a streamed reply. Not a limit on how long a reply may take. Default 600."),
discover: z.boolean().optional().describe("Merge ids and context lengths from GET /models."),
unload_url: Url.optional().describe("llama-swap style: frees VRAM when switching away."),
one_model_at_a_time: z.boolean().optional().describe("The server holds one model at a time (llama-swap in front of one GPU): a subagent on another of its models would unload the session's, so it is refused. The session's own model may still be its own subagent."),
unload_method: z.enum(["GET", "POST"]).optional().describe("How unload_url is called (default POST)."),
quirks: z
.strictObject({
stream_usage: Tristate.optional().describe("Ask for token usage in the stream (stream_options); auto learns when a server refuses it."),
prompt_progress: Tristate.optional().describe("Ask how far the server is through reading the prompt (llama.cpp's return_progress), shown while it reads; auto learns when a server refuses it, and one that ignores it shows nothing."),
think_tags: Tristate.optional().describe("<think>…</think> in a reply is taken as reasoning; off leaves it as text."),
max_tokens_field: z.enum(["max_tokens", "max_completion_tokens"]).optional().describe("OpenAI reasoning models refuse `max_tokens`; most local servers only know it."),
})
.optional()
.describe("Server oddities; auto is right almost always."),
models: z.record(z.string(), ModelSpec).default({}).describe("The models to offer, by the id the server knows them by."),
})
export type Connection = z.infer<typeof Connection>
/** Server oddities; auto is right almost always. */
export const Quirks = z
.strictObject({
stream_usage: Tristate.optional().describe("Ask for token usage in the stream (stream_options); auto learns when a server refuses it."),
prompt_progress: Tristate.optional().describe("Ask how far the server is through reading the prompt (llama.cpp's return_progress), shown while it reads; auto learns when a server refuses it, and one that ignores it shows nothing."),
think_tags: Tristate.optional().describe("<think>…</think> in a reply is taken as reasoning; off leaves it as text."),
max_tokens_field: z.enum(["max_tokens", "max_completion_tokens"]).optional().describe("OpenAI reasoning models refuse `max_tokens`; most local servers only know it."),
})
.optional()
.describe("Server oddities; auto is right almost always.")
/** The LLeMbas server as LLeMbas CLI speaks to it: its OpenAI-shaped `/v1` with this machine's
* token, and the models it serves. Not something a person writes — custom connections were removed
* in 2.0 (CLI rule 2: the CLI is LLeMbas-server-specific); `lembas login` writes the one `webui`
* entry below, and the loader turns it into this (lembas/webui.ts, expandWebui). */
export interface Connection {
base_url: string
api_key?: string
tls?: z.infer<typeof Tls>
/** Seconds of silence allowed in a reply (default 600). */
timeout?: number
quirks?: z.infer<typeof Quirks>
/** Extra headers and body fields for every request (none from a login; tests use them). */
headers?: Record<string, string>
body?: Record<string, unknown>
models: Record<string, ModelSpec>
}
/** A LLeMbas instance: its address and this machine's key, and nothing else. The models,
* their settings, and — where config.yaml says `provider: webui` — the voice and the web search come
@@ -101,20 +98,22 @@ export type Connection = z.infer<typeof Connection>
export const WebuiConnection = z.strictObject({
type: z.literal("webui").describe("A LLeMbas instance: its models, their settings, its voice and its web search."),
url: Url.describe("The instance's address, e.g. https://ai.example.org (no /v1)."),
api_key: z.string().optional().describe("This machine's token, as login wrote it: {file:~/.config/lembas/lembas/<name>.key}. Or key_cmd."),
key_cmd: z.string().optional(),
api_key: z.string().optional().describe("This machine's token, as login wrote it: {file:~/.config/lembas/lembas/<name>.key}."),
tls: Tls.optional().describe("For an instance behind a private CA: `ca`, the PEM file that signed its certificate."),
timeout: z.number().positive().optional().describe("Seconds of silence allowed in a reply, as for any connection (default 600)."),
quirks: Connection.shape.quirks,
quirks: Quirks,
models: z.record(z.string(), ModelSpec).optional().describe("Only to change what the instance says about a model, here: each entry goes over the instance's. Usually absent."),
})
export type WebuiConnection = z.infer<typeof WebuiConnection>
/** connections.yaml: the login, as `lembas login` writes it. An entry of any other kind — a custom
* connection from 1.x — is read past with a warning (config/load.ts), not refused, so an old file
* still starts. */
export const ConnectionsFile = z.strictObject({
connections: z
.record(z.string(), z.union([WebuiConnection, Connection]))
.record(z.string(), WebuiConnection)
.default({})
.describe("Each LLM endpoint by a name of your choosing; a model is then `name/model-id`. `type: webui` is a LLeMbas instance, whose models come from it."),
.describe("The LLeMbas server this machine is logged in to, as `lembas login` writes it: its address and this machine's token. Its models come from it at every start."),
})
export type ConnectionsFile = z.infer<typeof ConnectionsFile>
@@ -231,8 +230,8 @@ export const Update = z.strictObject({
export type Update = z.infer<typeof Update>
export const Config = z.strictObject({
model: z.string().optional().describe("The model a session starts with: `connection/model`, a connection from connections.yaml and one of its models."),
small_model: z.string().optional().describe("`connection/model` for small jobs — session titles, commit messages. Default: the session's model. On a server that holds one model at a time (llama-swap), another model here swaps."),
model: z.string().optional().describe("The model a session starts with: one the LLeMbas server you are logged in to serves, by its id there (`deepseek/deepseek-flash`). Default: the server's default for your account."),
small_model: z.string().optional().describe("The model for small jobs — session titles, commit messages, by its id on the server. Default: the session's model."),
titles: z.enum(["model", "prompt"]).optional().describe("Session titles: model (default) — named from the first prompt, then by the model after its first reply; prompt — only from the first prompt, no extra request."),
mode: z.enum(MODES).optional().describe("The permission mode a session starts in (default manual)."),
theme: z.string().optional().describe("Colours: moria (default), shire, lembas, lembas-light, hermes, mono, or a themes/<name>.yaml of your own."),
@@ -276,7 +275,7 @@ export const Config = z.strictObject({
})
.optional(),
question: z.strictObject({ unattended: z.enum(["recommended", "first", "fail"]).optional().describe("With nobody to answer (headless): take the recommended option, the first, or fail.") }).optional(),
compaction: z.strictObject({ auto_at: z.number().min(0.1).max(1).optional(), prune: z.boolean().optional() }).optional().describe("Automatic context management: past auto_at of the window, old tool outputs are pruned, then the conversation is compacted. auto_at: 1 turns it off."),
compaction: z.strictObject({ auto_at: z.number().min(0.1).max(1).optional() }).optional().describe("Automatic context management: past auto_at of the window, the conversation is compacted. auto_at: 1 turns it off. (Old tool outputs are never cut afterwards: that would make the server read the whole conversation again.)"),
instructions: z.string().max(4000).optional().describe("Custom instructions: how you want to be helped, in your own words (at most 4000 characters). Appended near the end of the system prompt, under \"## How the person you are talking to wants to be helped\" — as in the LLeMbas web UI. (A list of files here, from an older config, is read as instruction_files.)"),
instruction_files: z.array(z.string()).optional().describe("More instruction files for the system prompt, besides AGENTS.md / CLAUDE.md."),
search: z
@@ -293,12 +292,12 @@ export const Config = z.strictObject({
voice: Voice.optional().describe("Voice in and out. Global only — it carries endpoints and keys, like a connection."),
mcp: z.record(z.string(), McpServer).optional().describe("MCP servers by name. `{env:}`/`{file:}` are filled in per server (a missing one disables that server only). A project's servers count only once the project is trusted."),
knowledge: z.array(z.string()).optional().describe("The knowledge bases searched (lembas kb); absent: all of them. A project's own config narrows it to what that project needs."),
embedding: z.string().optional().describe("Global config only. connection/model of an embedding model (OpenAI-shaped /embeddings, or Ollama): knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it."),
embedding: z.string().optional().describe("Global config only. An embedding model the LLeMbas server serves, by its id there: knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it."),
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("The effort a session starts with, when the model takes it; otherwise the model's own default."),
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
prototypes: z.boolean().optional().describe("Global config only. Offer the model prototypes: a complete HTML page in a `prototype-chat` or `prototype-page` fence, saved as a file under the state directory (prototypes/<session>/) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."),
library: z.enum(["local", "lembas"]).optional().describe("Global config only. local (default): memory, notes, skills and knowledge on this machine. lembas: the account's on the LLeMbas instance you are logged in to, through its /mcp — the same tools its chats have (needs a login with the library scope)."),
library: z.enum(["local", "lembas"]).optional().describe("Global config only. Where memory lives. lembas (the default when the login may reach it): the account's memory records on the LLeMbas server, through its /mcp — the same records its chats have. local: this machine's memory files. Notes, knowledge bases and skills are this machine's either way."),
remote: z
.strictObject({
enabled: z.boolean().optional().describe("Accept work from a LLeMbas instance this machine is logged in to (lembas service). Default false."),
+1 -2
View File
@@ -19,7 +19,7 @@ export type Source = "default" | "global" | "project" | "session"
export interface SettingDef {
key: string
/** model: a configured `connection/model`; effort: one the model takes, or off; list: comma-separated. */
/** model: a model the server serves, by its id; effort: one the model takes, or off; list: comma-separated. */
kind: "enum" | "model" | "effort" | "number" | "boolean" | "list" | "string"
values?: readonly string[]
/** The agent may change it (through the settings tool, with approval). */
@@ -53,7 +53,6 @@ export const SETTINGS: SettingDef[] = [
{ key: "icons", kind: "enum", values: ["emoji", "plain"], agent: true, scopes: USER, fallback: "emoji" },
{ key: "mouse", kind: "boolean", agent: true, scopes: ["global"], reload: true, fallback: "true" },
{ key: "compaction.auto_at", kind: "number", agent: true, scopes: ALL, fallback: "0.85" },
{ key: "compaction.prune", kind: "boolean", agent: true, scopes: ALL, fallback: "true" },
{ key: "search.order", kind: "list", values: ["searxng", "firecrawl", "ddg"], agent: true, scopes: ALL, fallback: "those configured, then ddg" },
{ key: "search.max_results", kind: "number", agent: true, scopes: ALL, fallback: "8" },
{ key: "memory.enabled", kind: "boolean", agent: true, scopes: ALL, reload: true, fallback: "true" },
+69 -3
View File
@@ -7,6 +7,8 @@ import LOOP_JSON from "../harness/loop.json"
import t_apply_patch from "../harness/tools/apply_patch.json"
import t_ask_user from "../harness/tools/ask_user.json"
import t_bash from "../harness/tools/bash.json"
import t_branch from "../harness/tools/branch.json"
import t_commit from "../harness/tools/commit.json"
import t_decisions from "../harness/tools/decisions.json"
import t_edit from "../harness/tools/edit.json"
import t_glob from "../harness/tools/glob.json"
@@ -19,20 +21,31 @@ import t_knowledge_get from "../harness/tools/knowledge_get.json"
import t_knowledge_search from "../harness/tools/knowledge_search.json"
import t_list from "../harness/tools/list.json"
import t_memory from "../harness/tools/memory.json"
import t_merge from "../harness/tools/merge.json"
import t_mkdir from "../harness/tools/mkdir.json"
import t_multiedit from "../harness/tools/multiedit.json"
import t_mv from "../harness/tools/mv.json"
import t_note_manage from "../harness/tools/note_manage.json"
import t_note_view from "../harness/tools/note_view.json"
import t_notes_search from "../harness/tools/notes_search.json"
import t_plan_submit from "../harness/tools/plan_submit.json"
import t_plan_update from "../harness/tools/plan_update.json"
import t_plan_write from "../harness/tools/plan_write.json"
import t_pr from "../harness/tools/pr.json"
import t_python from "../harness/tools/python.json"
import t_read from "../harness/tools/read.json"
import t_recall from "../harness/tools/recall.json"
import t_release from "../harness/tools/release.json"
import t_report from "../harness/tools/report.json"
import t_rm from "../harness/tools/rm.json"
import t_search from "../harness/tools/search.json"
import t_session_search from "../harness/tools/session_search.json"
import t_settings from "../harness/tools/settings.json"
import t_show from "../harness/tools/show.json"
import t_skill_manage from "../harness/tools/skill_manage.json"
import t_skill_view from "../harness/tools/skill_view.json"
import t_skills_list from "../harness/tools/skills_list.json"
import t_tag from "../harness/tools/tag.json"
import t_task from "../harness/tools/task.json"
import t_tasks from "../harness/tools/tasks.json"
import t_view_image from "../harness/tools/view_image.json"
@@ -43,9 +56,11 @@ import { fillPrompt } from "./prompt/fill.ts"
export interface ToolSpecDef {
name: string
/** shared: both projects implement it; cli / llembas: only that one does. The execution tools
* (bash, edit, read…) are the CLI's: the web UI runs no agent of its own. */
/** shared: both projects implement it; cli / llembas: only that one does. */
scope: "shared" | "cli" | "llembas"
/** Spec 3.0.0: where it is offered — a chat on the server (its file system), an agent session
* on a device (this CLI, on its own disk), or both. */
sessions: "chat" | "agent" | "both"
risk: "read" | "write" | "execute" | "interact"
exclusive: boolean
/** How a call is shown in a transcript: a shell command, an edit with its diff, a
@@ -60,6 +75,15 @@ export interface ToolSpecDef {
/** `subagent`: offered only to a subagent (report), never to a session's own model. */
offered?: "subagent"
argument_aliases?: Record<string, string>
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's);
* `plan` (3.1.0): plan mode. From 3.1.0 one that can come and go during a session does not
* leave the tool out: it is offered and answers `unavailable` while unmet (context.md §10). */
requires?: ("display" | "page" | "forge" | "plan")[]
/** What a call answers, word for word, when a requirement went away after the offer. */
unavailable?: string
/** `always`: put to the person in every mode, auto included; never remembered. */
confirm?: "always"
/** `{{name}}` in the description: a fact the projects differ in, filled in by each (fillDescription). */
variables?: Record<string, string>
/** What a project offers of the parameters, where it offers less than all (narrowParameters). */
@@ -68,6 +92,8 @@ export interface ToolSpecDef {
parameters: Record<string, unknown>
}
/** `X.Y.Z`, or a SemVer pre-release while the spec is written ahead of both implementations
* (`3.0.0-draft`). */
export const HARNESS_VERSION = VERSION.trim()
/** How one prompt runs (harness/loop.json): the step backstop, the budgets' defaults, the wrap-up
@@ -87,6 +113,39 @@ export const LOOP: {
running_max: { default: number }
subagent_steps: { default: number }
}
/** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
* compaction adds. */
context: {
ids: { digits: number }
read_header: string
read_header_empty: string
edit_header: string
header: string
unchanged: string
same_result: string
elided: string
created: string
no_result: string
recall_header: string
sha_digits: number
cap: { max_lines: number; max_chars: number; head_lines: number; head_chars: number; tail_lines: number; tail_chars: number }
dedupe: { by_hash: string[]; by_result: string[] }
compaction: { heading: string; line: string; rows: number }
reasoning: string
/** A note on the next user message (spec 3.1.0, context.md §10): its tags and its lines, in
* the order they are said. */
note: { open: string; close: string; date: string; mode: string; plan: string; memory: string; instructions: string; personality: string; mcp: string; empty: string
/** What a note names the person's own instructions by (the instructions block's `{{path}}`). */
personal: string
/** For a chat, whose modes have no text of their own; the CLI says modes/<mode>.md. */
plan_ended: string
/** For a chat, which does not resend earlier tool results; the CLI's model has them. */
plan_own: string
}
/** How long a session waits for its configured MCP servers before its first request. */
mcp_wait_ms: number
}
} = LOOP_JSON
/** The `purpose` argument (harness/purpose.json). */
@@ -163,9 +222,16 @@ export function narrowParameters(parameters: Record<string, unknown>, narrow: Na
}
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
([t_apply_patch, t_ask_user, t_bash, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_multiedit, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_read, t_report, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
)
/** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and
* offered in an agent session (`sessions` agent or both) — a chat's tools over the server's tree
* (mkdir, mv, rm, search, python, the forge verbs) are not the CLI's. */
export function forCli(t: ToolSpecDef): boolean {
return t.scope !== "llembas" && t.sessions !== "chat"
}
/** A tool's name, description and `exclusive` flag as the spec has them, for its definition:
* `...described("read")`. With `vars`, the description filled and the values kept for its
* parameters (toSpec). */
+79 -31
View File
@@ -9,14 +9,26 @@ import { existsSync, readFileSync } from "node:fs"
import { hostname } from "node:os"
import { VERSION } from "../version.ts"
/** The device protocol this build speaks (LLeMbas `api/devices.py:PROTOCOL`): 2, the
/** The device protocol this build speaks (LLeMbas `services/device_link.py:PROTOCOL`): 3, the
* same number as the link's `_lembas/*` extension (acp/agent.ts LEMBAS_PROTOCOL). */
export const PROTOCOL = 2
/** The ones it can log in to: protocol 2 only adds what both sides name in their capabilities, so an
* instance still at 1 works with what it has. */
export const PROTOCOLS = [1, 2]
export const PROTOCOL = 3
/** The ones it can log in to and link with: 3 alone. LLeMbas CLI 2.0 needs a LLeMbas 2.0 server
* (its tree, its memory, its models only); a 1.x server speaks 1 and 2, and is refused with a word
* on what to update (harness/acp.md *Versions*). */
export const PROTOCOLS = [3]
/** What this CLI says to a server that does not speak its protocol — at login, and at a dial: a
* 1.x server (1 and 2) needs updating, a newer one needs a newer CLI. */
export function protocolMismatch(base: string, d: Pick<Discovery, "protocol" | "protocols" | "version">): string {
const theirs = Array.isArray(d.protocols) && d.protocols.length ? d.protocols : [d.protocol]
const said = `${base} runs LLeMbas ${d.version ?? "(an unknown version)"}, which speaks link protocol ${theirs.join(" and ")}`
if (theirs.every((p) => p > PROTOCOL)) return `${said}; this LLeMbas CLI ${VERSION} speaks ${PROTOCOL}. Update LLeMbas CLI (lembas update).`
return `${said}; this LLeMbas CLI ${VERSION} needs a LLeMbas 2.0 server (protocol ${PROTOCOL}). Update the server, or use LLeMbas CLI 1.x with it.`
}
export class LembasError extends Error {}
/** The server and this CLI speak no link protocol in common: no login, and no dial gets further. */
export class ProtocolError extends LembasError {}
export interface Discovery {
service: "lembas"
@@ -116,10 +128,10 @@ export function spoken(d: Pick<Discovery, "protocol" | "protocols">): number[] {
return PROTOCOLS.filter((p) => theirs.includes(p))
}
/** The `_lembas` protocol to say in the link's hello: 2 where the instance lists it, else
* 1 — an instance that speaks only 1 closes a link that says 2 (4400). Features stay gated by capabilities. */
export function linkProtocol(protocols: number[] | undefined): number {
return protocols?.includes(2) ? 2 : 1
/** The `_lembas` protocol to say in the link's hello: 3, the only one this build speaks. An
* instance that does not take it closes the link with 4400. Features stay gated by capabilities. */
export function linkProtocol(_protocols?: number[]): number {
return PROTOCOL
}
export async function discover(address: string, tls?: Tls): Promise<{ discovery: Discovery; tls?: Tls }> {
@@ -144,7 +156,7 @@ export async function discover(address: string, tls?: Tls): Promise<{ discovery:
throw new LembasError(`${base} answered /.well-known/lembas.json with something that is not JSON`)
}
if (body?.service !== "lembas") throw new LembasError(`${base} is not a LLeMbas instance`)
if (!spoken(body).length) throw new LembasError(`${base} speaks device protocol ${body.protocol}; this LLeMbas CLI speaks ${PROTOCOLS.join(" and ")}. Update the older of the two.`)
if (!spoken(body).length) throw new ProtocolError(protocolMismatch(base, body))
if (!body.login?.device?.code) throw new LembasError(`${base} offers no device login`)
return { discovery: body, tls: used }
}
@@ -158,7 +170,7 @@ export interface DeviceStart {
interval: number
}
export async function startDevice(d: Discovery, tls: Tls | undefined, scopes = ["models", "library", "link"]): Promise<DeviceStart> {
export async function startDevice(d: Discovery, tls: Tls | undefined, scopes = ["models", "memory", "link"]): Promise<DeviceStart> {
const res = await call(
d.login.device.code,
{
@@ -224,6 +236,34 @@ export async function awaitToken(d: Discovery, start: DeviceStart, tls: Tls | un
throw new LembasError("the code expired before it was approved — run login again")
}
/** The scopes a token carries, as the instance granted them (`models memory link`). */
export function scopesOf(scope: string | undefined): string[] {
return (scope ?? "").split(/\s+/).filter(Boolean)
}
/** `lembas login --token-stdin`: prove a token handed over by another program — GET
* /api/v1/instance with it, which says the account and what the token may do (`scope`) — and
* return it as a device login's poll would have. A token that cannot link is refused: it would
* give this machine models but no agent chats, and the program handing it over is after the
* agent. Nothing is written by this. */
export async function checkToken(d: Discovery, token: string, tls: Tls | undefined): Promise<Granted> {
const base = d.base_url
const host = new URL(base).host
const res = await call(`${base}/api/v1/instance`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls)
const older = `${base} runs LLeMbas ${d.version}, which cannot say what a token may do: lembas login --token-stdin needs a newer LLeMbas 2.0 server. Update the server, or sign in with lembas login ${base}`
if (res.status === 401) throw new LembasError(`${host} does not accept this token: it is revoked, expired, or not one of this instance's`)
if (res.status === 403) throw new LembasError(`${host} refused this token for its models: it needs the models scope, and the account the “Use the API” permission`)
if (res.status === 404) throw new LembasError(older)
if (!res.ok) throw new LembasError(`checking the token: ${res.status} ${res.statusText}`)
const body = (await res.json().catch(() => undefined)) as (InstanceInfo & { scope?: unknown; account?: { email?: string; name?: string } }) | undefined
if (!body || typeof body !== "object" || typeof body.scope !== "string") throw new LembasError(older)
const scopes = scopesOf(body.scope)
if (!scopes.includes("link"))
throw new LembasError(`this token cannot link this machine to ${host}: it carries ${scopes.length ? scopes.join(" ") : "no scope"}, and LLeMbas CLI's needs link (models memory link). Sign in with lembas login ${base} instead`)
const account = body.account && typeof body.account === "object" ? { email: body.account.email, name: body.account.name } : {}
return { access_token: token, scope: scopes.join(" "), account, base_url: base }
}
export async function models(d: { api: { openai: string } }, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<ServedModel[]> {
const res = await call(`${d.api.openai}/models`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
@@ -237,6 +277,10 @@ export async function models(d: { api: { openai: string } }, token: string, tls:
* which of voice and web search it may use through the API. */
export interface InstanceInfo {
name?: string
/** The account the token belongs to. */
account?: { email?: string; name?: string }
/** What the token presented may do, space-separated, as its login granted it. */
scope?: string
connection?: string
version?: string
default_model?: string | null
@@ -255,36 +299,40 @@ export async function instanceInfo(base: string, token: string, tls: Tls | undef
/** Revoke this machine's token on the instance. Best-effort: signing out locally goes ahead even
* when the instance cannot be reached, and says so. */
export async function signOut(baseUrl: string, token: string, tls: Tls | undefined): Promise<boolean> {
/** DELETE /api/v1/token: the token revokes itself. `replacedBy`, a login again's new token: an
* instance that knows it hands the old device's agent chats to the new one first (one that does
* not ignores the body and only revokes). */
export async function signOut(baseUrl: string, token: string, tls: Tls | undefined, replacedBy?: string): Promise<boolean> {
try {
const res = await call(`${baseUrl}/api/v1/token`, { method: "DELETE", headers: { authorization: `Bearer ${token}` } }, tls)
const headers: Record<string, string> = { authorization: `Bearer ${token}` }
if (replacedBy) headers["content-type"] = "application/json"
const body = replacedBy ? JSON.stringify({ replaced_by: replacedBy }) : undefined
const res = await call(`${baseUrl}/api/v1/token`, { method: "DELETE", headers, body }, tls)
return res.ok || res.status === 401
} catch {
return false
}
}
/** GET /v1/usage: the account's credits this month, and this device's share. Every
* number is null on an instance that keeps no credits or plans. */
/** GET /v1/usage, as the server's 2.0 answers it: counting and money, no credits. `plan`,
* `balance`, `balance_money`, the credit `cost` fields and `device.cost_money` are null and
* `windows` empty there; they are not read. */
export interface Usage {
plan: { name: string; credits_per_month: number } | null
balance: number | null
/** `cost` null where the instance keeps no credits. */
month: { from: string; tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
device: { tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
unit: string
// Every one optional: an instance may have none of them.
/** The money credits are counted in: "EUR", "USD" or "GBP". */
month: {
from: string
tokens_in: number
tokens_out: number
/** What the month cost in `currency` — null when it is in more than one currency. */
cost_money?: number | null
/** The month's cost per currency, `{ USD: 1.25, EUR: 0.4 }`: never added together. */
cost_by_currency?: Record<string, number>
}
device: { tokens_in: number; tokens_out: number }
/** The currency `month.cost_money` is in. */
currency?: string
/** What one credit is worth, in `currency`. */
credit_value?: number | null
/** An administrator: no limit, never refused — the spend is still counted. */
/** An administrator of the instance. */
admin?: boolean
/** The balance in `currency`. `month.cost_money`, `device.cost_money` likewise. */
balance_money?: number | null
/** Rolling spending windows (e.g. five hours, a week): how much of each is used, and when the
* oldest of it frees again. */
windows?: { window: string | number; limit: number | null; spent: number; frees_at?: string | null }[]
unit?: string
}
/** The usage, or undefined from an instance that does not have the endpoint. */
+56 -12
View File
@@ -7,12 +7,13 @@
// pasted into the YAML. The models are not written anywhere: a webui connection reads them from the
// instance at every start (webui.ts). `lembas.json` remembers which connections a login made, so
// `login` again refreshes them and `logout` removes exactly them. Without a login nothing here is
// read, and LLeMbas CLI runs on its hand-written config as before.
// read, and LLeMbas CLI has no model to talk to: 2.0 talks to the server it is logged in to, one at
// a time, and nothing else.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { join, resolve } from "node:path"
import { parseDocument } from "yaml"
import { globalPaths, isLocal, paths } from "../config/paths.ts"
import { awaitToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts"
import { awaitToken, checkToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type Granted, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
export interface Instance {
@@ -23,8 +24,9 @@ export interface Instance {
/** The instance's own name (Example). */
instance_name?: string
ca?: string
/** What the token may do, as the instance granted it: `library` in it makes the account's
* library the default. An older login has none recorded until it is made again. */
/** What the token may do, as the instance granted it: `memory` in it (or the older `library`)
* makes the account's memory the default. An older login has none recorded until it is made
* again. */
scope?: string
/** The link protocols the instance listed when last asked: at login, and again at every
* dial of the link, so an instance updated later is picked up. */
@@ -280,9 +282,8 @@ function currentModel(): string | undefined {
return typeof value === "string" && value ? value : undefined
}
/** Each served model's ref once this login is in place (by provider, `deepseek/x`; an
* instance that does not speak protocol 2, or a provider a connection of the user's own is named after, keeps
* `<connection>/<id>`). Read from the configuration as it now is. */
/** Each served model's ref once this login is in place: its served id (`deepseek/x`). Read from
* the configuration as it now is. */
async function refsOf(connection: string): Promise<Record<string, string>> {
try {
const { loadConfig } = await import("../config/load.ts")
@@ -390,12 +391,39 @@ function webuiCount(): number {
}
export async function login(address: string, o: LoginOptions): Promise<LoginResult> {
const { discovery, tls } = await discover(address, o.ca ? { ca: o.ca } : undefined)
// Kept as a full path: `lembas service` and Desktop read it from another directory.
const ca = o.ca ? resolve(expandHome(o.ca)) : undefined
const { discovery, tls } = await discover(address, ca ? { ca } : undefined)
const start = await startDevice(discovery, tls)
o.onCode(start, discovery)
const granted = await awaitToken(discovery, start, tls, { signal: o.signal, sleep: o.sleep })
return store(discovery, tls, granted, o)
}
/** `lembas login --token-stdin`: what a program that already holds an agent's token — LLeMbas
* Desktop, from the one approval that gave it its own token too — hands over. Checked against the
* instance before anything is written: it must speak this CLI's protocol, accept the token, and
* say the token carries `link`. Then stored exactly as a device login's is. */
export async function loginWithToken(address: string, token: string, o: Omit<LoginOptions, "onCode" | "sleep" | "signal"> = {}): Promise<LoginResult> {
const raw = token.trim()
if (!TOKEN.test(raw)) throw new LembasError("expected one LLeMbas token (lmb_…) on stdin, and nothing else")
const ca = o.ca ? resolve(expandHome(o.ca)) : undefined
const { discovery, tls } = await discover(address, ca ? { ca } : undefined)
const granted = await checkToken(discovery, raw, tls)
return store(discovery, tls, granted, o)
}
/** A token as the instance makes them: `lmb_` and URL-safe base64. */
const TOKEN = /^lmb_[A-Za-z0-9_-]+$/
/** What both kinds of login do once they hold a token: write it, replace an earlier login to the
* same instance (signing its token out, naming this one), sign out of any other instance, and set
* up the connection, voice, search and embedding. */
async function store(discovery: Discovery, tls: Tls | undefined, granted: Granted, o: Pick<LoginOptions, "setDefault" | "keepServices">): Promise<LoginResult> {
const connection = connectionNameFor(discovery.base_url, discovery.connection)
// Logging in again under the same name: the token being replaced, read before its file is.
const before = ownInstances()[connection]
const replaced = before?.base_url === discovery.base_url && existsSync(ownKeyFile(connection)) ? readFileSync(ownKeyFile(connection), "utf8").trim() : ""
mkdirSync(keyDir(), { recursive: true, mode: 0o700 })
writeFileSync(ownKeyFile(connection), granted.access_token + "\n", { mode: 0o600 })
chmodSync(ownKeyFile(connection), 0o600)
@@ -403,13 +431,29 @@ export async function login(address: string, o: LoginOptions): Promise<LoginResu
const instanceDefault = fetched.instance?.default_model ?? fetched.models.find((m) => m.default)?.id ?? undefined
const changes: string[] = []
// The old token is signed out too: this machine holds one, the new one. Over the TLS that has
// just worked (the old login's CA may be what was wrong), naming the new token, so the instance
// moves the old device's agent chats to it instead of keeping an offline twin.
const handOver = async (old: string) => {
if (old && old !== granted.access_token && !(await signOut(discovery.base_url, old, tls, granted.access_token)))
changes.push("the previous login could not be signed out: revoke it under User → Security → Devices on the instance")
}
await handOver(replaced)
// One server at a time (2.0: the CLI is LLeMbas-server-specific): a login to another one signs
// this machine out of the one it was logged in to.
for (const [name, old] of Object.entries(ownInstances()))
if (name !== connection && old.base_url !== discovery.base_url) {
const out = await logout(name).catch(() => undefined)
if (!out) continue
changes.push(`signed out of ${old.instance_name ?? old.base_url} (${name}): LLeMbas CLI uses one server at a time`)
if (out.modelLeft) changes.push(`config.yaml still starts on ${out.modelLeft}, a model of that server: change it with lembas config set model <id>, or remove it to start on this server's default`)
}
// Only logins this mode made: in local mode, the user's own login to the same instance is not
// signed out or replaced — it is not this project's.
const known = ownInstances()
for (const [name, old] of Object.entries(known))
if (name !== connection && old.base_url === discovery.base_url) {
// The old token is signed out too: this machine holds one, the new one.
if (existsSync(ownKeyFile(name))) await signOut(old.base_url, readFileSync(ownKeyFile(name), "utf8").trim(), old.ca ? { ca: old.ca } : undefined)
if (existsSync(ownKeyFile(name))) await handOver(readFileSync(ownKeyFile(name), "utf8").trim())
changes.push(...replaceOld(name, connection, instanceDefault))
delete known[name]
}
+31 -41
View File
@@ -7,8 +7,9 @@
// personality, personality_custom, instructions — from GET /v1/me/personalization when the
// instance has it `available` (its administrator allows it); /settings writes them back there
// with PUT. Read with the models at every start (webui.ts) and kept in the same cache.
// library — with no `library:` written anywhere, a login whose token has the `library` scope
// means the account's library (`lembas`); `library: local` written keeps this machine's.
// library — with no `library:` written anywhere, a login whose token has the `memory` scope
// (or the older `library`) means the account's memory (`lembas`); `library: local` written
// keeps this machine's.
import { existsSync, readFileSync } from "node:fs"
import { isLocal } from "../config/paths.ts"
import type { Config } from "../config/schema.ts"
@@ -30,10 +31,11 @@ export function soleInstance(): { instance: Instance; token: string } | undefine
}
}
/** §10: the library a config with no `library:` uses — the account's, when the login may read it. */
/** Where memory lives when config.yaml has no `library:` — the server's, when the login's token
* may reach it (scope `memory`, or the older `library`; harness/acp.md *Memory*). */
export function defaultLibrary(): "lembas" | undefined {
const one = soleInstance()
return one?.instance.scope?.split(/\s+/).includes("library") ? "lembas" : undefined
const scopes = soleInstance()?.instance.scope?.split(/\s+/) ?? []
return scopes.includes("memory") || scopes.includes("library") ? "lembas" : undefined
}
/** The instance's personalization as last read, when it is the one that counts. */
@@ -87,13 +89,7 @@ export async function accountUsage(timeoutMs?: number): Promise<{ connection: st
const n = (x: number) => Math.round(x).toLocaleString("en")
/** Credits as a person reads them: two decimals where the number is small enough for them
* to matter (a balance of 3.47 is not "3"), whole above a thousand, as before. */
export function credits(x: number): string {
return x.toLocaleString("en", { maximumFractionDigits: Math.abs(x) < 1000 ? 2 : 0 })
}
/** An amount in the instance's currency: `€1.23`, `$1.23`, `£1.23` — or "" without one. */
/** An amount in a currency: `€1.23`, `$1.23`, `£1.23` — or "" without one. */
export function money(x: number | null | undefined, currency: string | undefined): string {
if (x === null || x === undefined || !Number.isFinite(x) || !currency) return ""
try {
@@ -104,41 +100,35 @@ export function money(x: number | null | undefined, currency: string | undefined
}
}
/** A credit amount's money, as the instance gave it, else from what a credit is worth. */
function moneyOf(u: Usage, given: number | null | undefined, amount: number | null | undefined): string {
if (given !== null && given !== undefined) return money(given, u.currency)
if (amount === null || amount === undefined || typeof u.credit_value !== "number") return ""
return money(amount * u.credit_value, u.currency)
/** The month's cost, one amount per currency (`€2.00 · $0.75`) — never two currencies added
* together — or "" when nothing was priced. */
export function monthCost(u: Usage | undefined): string {
const by = u?.month?.cost_by_currency
if (by && typeof by === "object" && !Array.isArray(by)) {
const parts = Object.entries(by)
.filter(([, v]) => typeof v === "number" && v > 0)
.sort(([a], [b]) => a.localeCompare(b))
.map(([code, v]) => money(v, code))
.filter(Boolean)
return parts.join(" · ")
}
const one = u?.month?.cost_money
return typeof one === "number" && one > 0 ? money(one, u?.currency) : ""
}
const withMoney = (text: string, m: string) => (m ? `${text} (${m})` : text)
/** The status bar's word: the balance, when the account has a plan, with its money when the
* instance says it; `unlimited` for an administrator; "" otherwise. */
export function balanceLabel(u: Usage | undefined): string {
if (u?.admin === true) return "unlimited"
if (!u?.plan || u.balance === null || u.balance === undefined) return ""
return withMoney(`${credits(u.balance)} ${u.unit || "credits"}`, moneyOf(u, u.balance_money, u.balance))
/** The status bar's word: what the month has cost so far, per currency; "" when nothing was
* priced or the instance cannot say. */
export function spendLabel(u: Usage | undefined): string {
const cost = monthCost(u)
return cost ? `${cost} this month` : ""
}
/** /usage: the detail, in lines. */
export function usageLines(connection: string, u: Usage): string[] {
const unit = u.unit || "credits"
const amount = (c: number | null | undefined, m: number | null | undefined) => (c !== null && c !== undefined ? ` — ${withMoney(`${credits(c)} ${unit}`, moneyOf(u, m, c))}` : m !== null && m !== undefined ? ` — ${money(m, u.currency)}` : "")
const head = u.admin === true
? `${connection}: unlimited (administrator)`
: `${connection}: ${u.plan ? `plan ${u.plan.name}, ${n(u.plan.credits_per_month)} ${unit} a month` : "no plan"}${u.balance !== null && u.balance !== undefined ? ` · balance ${withMoney(`${credits(u.balance)} ${unit}`, moneyOf(u, u.balance_money, u.balance))}` : ""}`
const out = [head]
const out = [u.admin === true ? `${connection} (administrator)` : connection]
const since = u.month?.from ? u.month.from.slice(0, 10) : "the start of the month"
if (u.month) out.push(`this month (since ${since}): ${n(u.month.tokens_in)} tokens in, ${n(u.month.tokens_out)} out${amount(u.month.cost, u.month.cost_money)}`)
if (u.device) out.push(`this device: ${n(u.device.tokens_in)} in, ${n(u.device.tokens_out)} out${amount(u.device.cost, u.device.cost_money)}`)
// The rolling windows: an administrator has none that bind, so none are listed.
if (u.admin !== true)
for (const w of Array.isArray(u.windows) ? u.windows : []) {
if (!w || typeof w.spent !== "number") continue
const frees = typeof w.frees_at === "string" && w.frees_at ? `, frees from ${w.frees_at.slice(0, 16).replace("T", " ")}` : ""
const of = typeof w.limit === "number" ? ` of ${credits(w.limit)}` : ""
out.push(`window ${w.window}: ${credits(w.spent)}${of} ${unit} spent${frees}`)
}
const cost = monthCost(u)
if (u.month) out.push(`this month (since ${since}): ${n(u.month.tokens_in)} tokens in, ${n(u.month.tokens_out)} out${cost ? ` — ${cost}` : ""}`)
if (u.device) out.push(`this device: ${n(u.device.tokens_in)} in, ${n(u.device.tokens_out)} out`)
return out
}
+2 -14
View File
@@ -19,7 +19,7 @@ import { join } from "node:path"
import { parse } from "yaml"
import { connectionFiles, isLocal, paths } from "../config/paths.ts"
import type { Connection, ModelSpec, WebuiConnection } from "../config/schema.ts"
import { runKeyCmd, substituteDeep } from "../config/substitute.ts"
import { substituteDeep } from "../config/substitute.ts"
import { instanceInfo, LembasError, models as listModels, normalise, personalization, type InstanceInfo, type Personalization, type ServedModel, type Tls } from "./client.ts"
/** What a loaded webui connection carries besides an openai-chat connection's fields. */
@@ -33,14 +33,9 @@ export interface WebuiMark {
services?: InstanceInfo["services"]
/** When the models were last read from the instance; absent: never. */
fetched_at?: string
/** Each served model's provider, where the instance said it (`provider`). */
providers?: Record<string, string>
/** The served ids of its embedding models, which are not chat models and so not in
* `models`: `embedding: llama/nomic-embed` finds its connection by them (library/embed.ts). */
embeddings?: string[]
/** The instance speaks link protocol 2: its ids are `<provider>/<model>` even
* where a model does not say its provider. Set by loadConfig from the login. */
v2?: boolean
}
export type WebuiLoaded = Connection & { webui: WebuiMark }
@@ -134,18 +129,12 @@ export function expandWebui(name: string, w: WebuiConnection, cache = readCache(
for (const m of ordered) models[m.id] = { ...specFor(m, name), ...(overrides.get(m.id) ?? {}) }
// A model the entry names that the instance does not (yet) list: kept, as written.
for (const [id, spec] of unmatched) models[id] ??= spec
const providers = Object.fromEntries(served.filter((m) => m.provider && m.id.startsWith(`${m.provider}/`)).map((m) => [m.id, m.provider!]))
return {
dialect: "openai-chat",
base_url: `${url}/v1`,
...(w.api_key !== undefined ? { api_key: w.api_key } : {}),
...(w.key_cmd !== undefined ? { key_cmd: w.key_cmd } : {}),
...(w.tls ? { tls: w.tls } : {}),
...(w.timeout ? { timeout: w.timeout } : {}),
...(w.quirks ? { quirks: w.quirks } : {}),
// The instance decides which models exist: one it serves that the cache does not know yet is
// still a model, not an error.
discover: true,
models,
webui: {
url,
@@ -153,7 +142,6 @@ export function expandWebui(name: string, w: WebuiConnection, cache = readCache(
...(cache?.instance?.name ? { name: cache.instance.name } : {}),
...(cache?.instance?.services ? { services: cache.instance.services } : {}),
...(cache && cache.url === url ? { fetched_at: cache.fetched_at } : {}),
...(Object.keys(providers).length ? { providers } : {}),
...(embeddings.length ? { embeddings } : {}),
},
}
@@ -200,7 +188,7 @@ export async function refreshWebui(o: { timeoutMs?: number; only?: string } = {}
entries.map(async ([name, c]): Promise<Refreshed> => {
try {
const w = substituteDeep(c, `connections.${name}`) as WebuiConnection
const token = w.api_key ?? (w.key_cmd ? runKeyCmd(w.key_cmd, `connections.${name}.key_cmd`) : undefined)
const token = w.api_key
if (!token) throw new LembasError("no key (lembas login)")
const got = await fetchWebui(name, w.url, token.trim(), w.tls, o.timeoutMs ?? 4000)
delete refreshFailures[name]
+19 -33
View File
@@ -1,6 +1,6 @@
// Embeddings for the library: `embedding: connection/model` in config.yaml, over the connection's
// OpenAI-shaped /embeddings (llama.cpp with --embedding, vLLM, LM Studio, OpenAI…) or Ollama's
// /api/embed. Vectors are made unit length here, so a dot product is the cosine.
// Embeddings for the library: `embedding:` in config.yaml names an embedding model of the LLeMbas
// server, reached at its OpenAI-shaped /v1/embeddings. Vectors are made unit length here, so a dot
// product is the cosine.
import { resolveKey, type Loaded } from "../config/load.ts"
import { authHeaders, joinUrl, request, tlsFor } from "../provider/http.ts"
import { resolveModel } from "../provider/index.ts"
@@ -15,46 +15,32 @@ function unit(v: number[]): Float32Array {
export function embedderFor(loaded: Loaded, ref: string | undefined): Embedder | undefined {
if (!ref) return undefined
// Through the same resolver as every model ref: an instance's model by its provider
// (`llama/nomic-embed`) or in the older form (`example/nomic-embed`). The name stored with the
// vectors stays the ref as written, so a library indexed under it is still found.
// An embedding model need not be listed on its connection (`connection/<any id>`, as before).
let name: string
// An embedding model of the server, by its served id (`llama/nomic-embed`) — not a chat model, so
// not a model ref (lembas/webui.ts, isChatModel): found among the ids the server says it serves
// for embeddings, or as a ref in any of its forms. The name stored with the vectors stays the ref
// as written, so a library indexed under it is still found.
const [name, c] = Object.entries(loaded.connections)[0] ?? []
if (!name || !c) throw new Error("embedding: not logged in to a LLeMbas server (lembas login)")
let id: string
try {
const resolved = resolveModel(loaded, ref)
name = resolved.connectionName
id = resolved.id
} catch (e) {
// An instance's embedding model by its served id (`llama/nomic-embed`): not a
// model ref, since only chat models are (lembas/webui.ts, isChatModel), so found by the ids
// its connection says it serves.
const served = Object.entries(loaded.connections).find(([, c]) => (c as { webui?: { embeddings?: string[] } }).webui?.embeddings?.includes(ref))
const slash = ref.indexOf("/")
if (served) {
name = served[0]
id = ref
} else {
if (slash <= 0) throw new Error(`embedding: "${ref}" must be written connection/model`)
name = ref.slice(0, slash)
id = ref.slice(slash + 1)
if (!loaded.connections[name]) throw new Error(`embedding: ${(e as Error).message}`)
if (c.webui?.embeddings?.includes(ref)) id = ref
else if (ref.startsWith(`${name}/`) && c.webui?.embeddings?.includes(ref.slice(name.length + 1))) id = ref.slice(name.length + 1)
else {
try {
id = resolveModel(loaded, ref).id
} catch (e) {
throw new Error(`embedding: ${(e as Error).message}`)
}
}
const c = loaded.connections[name]!
if (c.dialect === "anthropic" || c.dialect === "gemini") throw new Error(`embedding: the ${c.dialect} dialect has no embeddings here; use an OpenAI-compatible or Ollama connection`)
const ollama = c.dialect === "ollama"
return {
model: ref,
async embed(texts, signal) {
const res = await request(
joinUrl(c.base_url, ollama ? "api/embed" : "embeddings"),
joinUrl(c.base_url, "embeddings"),
{ method: "POST", headers: authHeaders(c, resolveKey(name, c)), body: JSON.stringify({ model: id, input: texts }), signal, timeoutMs: (c.timeout ?? 120) * 1000, tls: tlsFor(c) },
`${ref} (embeddings)`,
)
const j = (await res.json()) as { data?: { embedding: number[]; index?: number }[]; embeddings?: number[][] }
const rows = ollama ? (j.embeddings ?? []) : [...(j.data ?? [])].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)).map((d) => d.embedding)
return rows.map(unit)
const j = (await res.json()) as { data?: { embedding: number[]; index?: number }[] }
return [...(j.data ?? [])].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)).map((d) => unit(d.embedding))
},
}
}
+28 -7
View File
@@ -25,6 +25,7 @@ import type { ImagePart } from "../provider/types.ts"
import { ToolError, truncate, type Tool, type ToolResult } from "../tool/tool.ts"
import { match } from "../permission/wildcard.ts"
import { codeFrom, forget, OAuthProvider, waitForCallback, CALLBACK_PORT } from "./oauth.ts"
import { mcpBlock } from "../session/prefix.ts"
export type ServerConfig = McpServer & { source: "global" | "project" }
export type Status = "disabled" | "connecting" | "connected" | "failed" | "needs_auth"
@@ -74,8 +75,9 @@ export interface McpPromptCommand {
const sanitize = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, "_")
/** The LLeMbas library server (`library: lembas`): its tools keep their own names, as in a
* LLeMbas chat, and stand in for this machine's memory, notes, skills and knowledge tools. */
/** The LLeMbas server's /mcp, for its memory (`library: lembas`, harness/acp.md *Memory*): its
* `memory` tool keeps its name and stands in for this machine's; notes, knowledge and skills stay
* this machine's (spec 3.0.0). */
export const LIBRARY_SERVER = "lembas-library"
/** mcp__<server>__<tool>, at most 64 characters (the providers' limit); longer ones keep a hash. */
export function toolName(server: string, tool: string): string {
@@ -361,6 +363,14 @@ export class McpManager {
return s.client
}
/** A resource's text, read from a server directly (not offered to the model): the LLeMbas
* server's `lembas://memory` for the prompt. `scope` rides as on a tool call. */
async readResource(server: string, uri: string, scope?: string): Promise<string> {
const c = await this.clientOf(server)
const r = await c.readResource({ uri, ...(scope ? { _meta: { lembas: { scope } } } : {}) })
return r.contents.map((x) => ("text" in x && typeof x.text === "string" ? x.text : "")).join("\n").trim()
}
/** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
tools(): Tool[] {
const out: Tool[] = []
@@ -386,13 +396,13 @@ export class McpManager {
description: library ? (t.description ?? t.name).trim() : `${(t.description ?? `${t.name} from the MCP server ${s.name}`).trim()}\n(MCP server: ${s.name})`,
schema: LOOSE,
jsonSchema: objectSchema(t.inputSchema),
// The library's writes are the account's memory and notes, not this machine: asked like
// the local memory tool (interact), never run unasked as a command would be denied.
// The library's writes are the account's memory, not this machine: asked like the local
// memory tool (interact), never run unasked as a command would be denied.
permission: () => ({ permission: name, class: readOnly ? "read" : library ? "interact" : "execute", patterns: ["*"] }),
run: async (args, ctx) => {
const c = await this.clientOf(s.name)
try {
// The instance's library answers within the chat's scope (harness/acp.md, Scope).
// The server's memory answers within the chat's scope (harness/acp.md, Scope).
const scope = library ? ctx.libraryScope : undefined
const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown>, ...(scope ? { _meta: { lembas: { scope } } } : {}) }, undefined, {
signal: ctx.signal,
@@ -471,8 +481,13 @@ export class McpManager {
/** The system prompt's block of server instructions, or "". */
instructions(): string {
const parts = [...this.servers.values()].filter((s) => s.status === "connected" && s.instructions).map((s) => `<server name="${s.name}">\n${s.instructions}\n</server>`)
return parts.length ? `Instructions from the MCP servers you have tools from (their tools are named mcp__<server>__…):\n<mcp_instructions>\n${parts.join("\n")}\n</mcp_instructions>` : ""
return mcpInstructions(this.serverInstructions())
}
/** Each connected server's own instructions, by server: what the prompt shows (mcpInstructions),
* and what a session note compares (session/prefix.ts). */
serverInstructions(): Record<string, string> {
return Object.fromEntries([...this.servers.values()].filter((s) => s.status === "connected" && s.instructions).map((s) => [s.name, s.instructions!]))
}
/** Sign in to a remote server: show the page, catch the redirect (or take a pasted one). */
@@ -545,3 +560,9 @@ function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T>
let t: ReturnType<typeof setTimeout>
return Promise.race([p, new Promise<T>((_, reject) => (t = setTimeout(() => reject(new Error(message)), ms)))]).finally(() => clearTimeout(t))
}
/** MCP servers' instructions as the system prompt carries them, "" for none. */
export function mcpInstructions(by: Record<string, string>): string {
const parts = Object.entries(by).map(([name, text]) => mcpBlock(name, text))
return parts.length ? `Instructions from the MCP servers you have tools from (their tools are named mcp__<server>__…):\n<mcp_instructions>\n${parts.join("\n")}\n</mcp_instructions>` : ""
}
+12 -2
View File
@@ -1,17 +1,21 @@
// Images for vision models: read, size-checked, downscaled when ImageMagick is there.
import { readFileSync, statSync } from "node:fs"
import { extname } from "node:path"
import { imageSize } from "../provider/tokens.ts"
import type { ImagePart } from "../provider/types.ts"
export const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
const MAX_BYTES = 3_500_000
const SHRINK_ABOVE_BYTES = 500_000
const MAX_EDGE = 1600
const magick = Bun.which("magick") ?? Bun.which("convert")
export function isImage(path: string): boolean {
return extname(path).toLowerCase() in IMAGE_TYPES
}
/** The image as a part, or a reason it cannot be one. Large ones are shrunk to 1600 px if possible. */
/** The image as a part, or a reason it cannot be one. Anything over 1600 px or 500 KB is shrunk to
* 1600 px when ImageMagick is there. */
export function loadImage(path: string): ImagePart | string {
const mime = IMAGE_TYPES[extname(path).toLowerCase()]
if (!mime) return `${path} is not a supported image (png, jpg, gif, webp)`
@@ -21,7 +25,13 @@ export function loadImage(path: string): ImagePart | string {
} catch {
return `${path} does not exist`
}
if (size <= MAX_BYTES) return { type: "image", mime, data: readFileSync(path).toString("base64") }
const bytes = readFileSync(path)
const dims = imageSize(bytes)
// A screenshot of a large monitor is sent at 1600 px on its longer edge, as the LLeMbas server
// scales uploads: a model sees no more detail in 2560 px than it is billed tokens for, and a
// half-megabyte PNG is a slow request for nothing. Without ImageMagick it goes as it is.
const big = size > SHRINK_ABOVE_BYTES || (dims !== undefined && Math.max(dims.width, dims.height) > MAX_EDGE)
if (size <= MAX_BYTES && (!big || !magick)) return { type: "image", mime, data: bytes.toString("base64") }
if (!magick) return `${path} is ${(size / 1e6).toFixed(1)} MB — too large to send, and ImageMagick is not installed to shrink it`
// The coder named from the extension: ImageMagick otherwise decides by content, and a file named
// .png that is really SVG or MVG can make it read other files or fetch URLs.
+1 -1
View File
@@ -8,7 +8,7 @@ const CONFIG_TEMPLATE = `# LLeMbas CLI settings for this project. Merged over ~/
# and read only while the project is trusted. Connections cannot be set here.
#
# mode: edit # manual | edit | auto | plan
# model: connection/model
# model: provider/model
# permission:
# bash:
# "npm test *": allow
+1 -1
View File
@@ -6,7 +6,7 @@
// What it reads from outside: only the login — the user's connections.yaml, lembas.json and the key
// files they name — read-only, so the connections and sign-ins work without being copied. The
// user's config.yaml is not read; the local one is this mode's global config. A LLeMbas instance
// is used for models, chat, speech in and out, and the balance, and for nothing that is kept there
// is used for models, chat, speech in and out, and the usage, and for nothing that is kept there
// or shown there: no link, no shared sessions, no deletes sent, no server library, no search or
// fetch through it, no embeddings through it, no account personalization.
import { chmodSync, existsSync, mkdirSync } from "node:fs"
+90 -35
View File
@@ -1,6 +1,6 @@
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
import { platform, release } from "node:os"
import { dirname, join, resolve, sep } from "node:path"
import { dirname, join, relative, resolve, sep } from "node:path"
import type { Mode } from "../config/schema.ts"
import { expandHome, paths } from "../config/paths.ts"
import { render, type Plan } from "../plan/plan.ts"
@@ -16,6 +16,7 @@ import blockAgentsKeep from "../../harness/prompts/blocks/agents-keep.md" with {
import blockLibrary from "../../harness/prompts/blocks/library.md" with { type: "text" }
import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" }
import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type: "text" }
import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" }
import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" }
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
@@ -39,8 +40,9 @@ import personalityFunny from "../../harness/prompts/personality/funny.md" with {
import personalityFormal from "../../harness/prompts/personality/formal.md" with { type: "text" }
import personalitySocratic from "../../harness/prompts/personality/socratic.md" with { type: "text" }
// Built-in prompt text, by path relative to harness/prompts (the harness spec shared with LLeMbas).
// Any of these can be replaced by a file at the same path under ~/.config/lembas/prompts/.
// Built-in prompt text, by path relative to harness/prompts (the harness spec shared with LLeMbas):
// every text whose scope is shared or cli. The server's own (blocks/workspace.md, a chat's view of
// its file system) is not here. Any of these can be replaced by a file at the same path under ~/.config/lembas/prompts/.
const BUILTIN: Record<string, string> = {
"system/default.md": defaultSystem,
"system/identity.md": identity,
@@ -53,6 +55,7 @@ const BUILTIN: Record<string, string> = {
"blocks/library.md": blockLibrary,
"blocks/prototypes.md": blockPrototypes,
"blocks/subagent.md": blockSubagent,
"blocks/context.md": blockContext,
"agents/explore.md": agentExplore,
"agents/general.md": agentGeneral,
"modes/manual.md": modeManual,
@@ -132,8 +135,6 @@ export function knowledgeScope(names: string[] | undefined): string {
return `Knowledge searches here cover only: ${names.join(", ")}. Finding nothing there means nothing is there, not that the library is empty.`
}
/** Instruction files: the global AGENTS.md, then the nearest project AGENTS.md or CLAUDE.md
* walking up to the project root (first match wins, as OpenCode does — ancestors do not stack). */
/** An `instructions` entry from config.yaml. A project's may only name files inside the project:
* a cloned repository must not be able to put ~/.ssh/… into what is sent to the model. */
export interface ExtraInstruction {
@@ -141,29 +142,58 @@ export interface ExtraInstruction {
global: boolean
}
export function instructionFiles(cwd: string, root: string, extra: ExtraInstruction[] = []): { path: string; text: string }[] {
const out: { path: string; text: string }[] = []
/** The names a directory's instruction file may have, in order of preference: one per directory. */
export const INSTRUCTION_NAMES = ["AGENTS.md", "CLAUDE.md"] as const
/** The stacked instruction chain (harness spec 3.0.0, filesystem.md *Instruction files, stacked*;
* conformance `instructions`): from the boundary down to the start directory, outermost first, each
* directory gives the first of AGENTS.md, CLAUDE.md that `readable` accepts — never both; one that is
* not readable is passed over and the walk goes on below it. A start outside the boundary is read as
* the boundary. Paths only, nothing read: the CLI answers `readable` from the disk, the conformance
* runner from a case's files and grants. */
export function instructionChain(boundary: string, start: string, readable: (path: string) => boolean): string[] {
const top = resolve(boundary)
let at = resolve(start)
if (at !== top && !at.startsWith(top === sep ? top : top + sep)) at = top
const dirs: string[] = []
for (let d = at; ; d = dirname(d)) {
dirs.unshift(d)
if (d === top || dirname(d) === d) break
}
const out: string[] = []
for (const d of dirs) {
const hit = INSTRUCTION_NAMES.map((f) => join(d, f)).find(readable)
if (hit) out.push(hit)
}
return out
}
/** Instruction files: the global AGENTS.md, then the project's chain from its root down to the
* working directory (instructionChain: every directory's AGENTS.md or CLAUDE.md, outermost first —
* before 3.0.0 only the nearest one counted), then the configured `instructions`. `chain` marks the
* chain's files: they are the project's own (agentsBlock), named to the model relative to the root. */
export function instructionFiles(cwd: string, root: string, extra: ExtraInstruction[] = []): { path: string; text: string; chain?: true }[] {
const out: { path: string; text: string; chain?: true }[] = []
const global = join(paths.config, "AGENTS.md")
if (existsSync(global)) out.push({ path: global, text: readFileSync(global, "utf8") })
let dir = cwd
let realRoot = root
try {
realRoot = realpathSync(root)
} catch {}
for (;;) {
const hit = ["AGENTS.md", "CLAUDE.md"].map((f) => join(dir, f)).find((f) => existsSync(f))
if (hit) {
// Read only when it really is a file in the project: a repository could commit AGENTS.md as
// a link to anything readable, and it would go to the model in every trust state.
try {
const real = realpathSync(hit)
if ((real === realRoot || real.startsWith(realRoot + sep)) && statSync(real).isFile()) out.push({ path: hit, text: readFileSync(real, "utf8") })
} catch {}
break
// Read only when it really is a file in the project: a repository could commit AGENTS.md as a
// link to anything readable, and it would go to the model in every trust state.
const texts = new Map<string, string>()
const readable = (path: string) => {
try {
const real = realpathSync(path)
if ((real !== realRoot && !real.startsWith(realRoot + sep)) || !statSync(real).isFile()) return false
texts.set(path, readFileSync(real, "utf8"))
return true
} catch {
return false
}
if (dir === root || dirname(dir) === dir) break
dir = dirname(dir)
}
for (const path of instructionChain(root, cwd, readable)) out.push({ path, text: texts.get(path)!, chain: true })
for (const e of extra) {
const path = resolve(root, expandHome(e.path))
if (out.some((f) => f.path === path)) continue
@@ -195,14 +225,14 @@ export interface PromptContext {
/** describeRepo(): branch, changes, recent commits. */
git?: string
/** The instruction files (AGENTS.md…) as read at the session's start; read now when absent. */
files?: { path: string; text: string }[]
files?: { path: string; text: string; chain?: true }[]
/** Open items on the project's task board. */
tasks?: string
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
* `report`: it has the report tool. */
subagent?: { name: string; instructions: string; report?: boolean }
/** The memory snapshot taken when the session started ("" when both files are empty);
* undefined when memory is off. */
/** The memory as it was when the prompt was made ("" when both files are empty); undefined
* when memory is off. A later change by the person goes as a note. */
memory?: string
/** The skills list (skillLines), taken when the session started. */
skills?: string
@@ -216,7 +246,8 @@ export interface PromptContext {
mcp?: string
/** The other configured models, one line each (with their notes). */
roster?: string
/** The session's plan (plan_write / plan_update), put into the prompt while it exists. */
/** The session's plan (plan_write / plan_update) as it stood when the prompt was made; a change
* after that is the model's own tool result, or a note when the person made it. */
plan?: Plan
/** An instance's own instructions for this session (the chat's authored prompt, over the link),
* appended after the shared text under `## Instructions from <name>`. */
@@ -224,6 +255,8 @@ export interface PromptContext {
/** The library block (blocks/library.md): set when knowledge tools are offered or the instance
* library is in use; `scope` the sentence naming the bases in scope, or "" for all of them. */
library?: { scope: string }
/** recall is offered: the tool results carry ids and may be stubs (blocks/context.md, spec 3.0.0). */
context?: boolean
/** Prototypes are offered (blocks/prototypes.md); `sources` the sentence saying what a page
* may load, or "" where nothing says. */
prototypes?: { sources: string }
@@ -231,6 +264,13 @@ export interface PromptContext {
const MAX_INSTRUCTION_CHARS = 40_000
/** The system prompt (harness spec; its stability is context.md §10, from 3.1.0). It is made once
* per session — at the start, after a compaction, on a model switch — and the engine resends
* those bytes on every request, so nothing in it may be a clock or anything else that moves on
* its own: no date (a note on the first user message says it, session/prefix.ts), the permission
* mode's text as it was at the start, the plan as it stood then, memory, instruction files,
* personality and MCP instructions as they were read for it. What changes later reaches the model
* as a `<session-note>` on the next user message, never by making this again. */
export function assembleSystem(ctx: PromptContext): string {
const vars = {
agent_name: ctx.agentName ?? "LLeMbas",
@@ -265,7 +305,7 @@ export function assembleSystem(ctx: PromptContext): string {
`Project root: ${ctx.root}`,
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
`Platform: ${platform()} ${release()}`,
`Today: ${new Date().toISOString().slice(0, 10)}`,
// No date: it would change at midnight and the whole session be read again (§10 rule 2).
`Model: ${ctx.modelRef}`,
"</env>",
].join("\n"),
@@ -280,14 +320,16 @@ export function assembleSystem(ctx: PromptContext): string {
let text = f.text.trim()
if (text.length > MAX_INSTRUCTION_CHARS)
text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${f.path} for the rest]`
sections.push(`Instructions from ${f.path}:\n<instructions>\n${text}\n</instructions>`)
// The chain's files by their path in the project (the spec's "relative to the boundary").
const named = f.chain ? relative(ctx.root, f.path) || f.path : f.path
sections.push(`Instructions from ${named}:\n<instructions>\n${text}\n</instructions>`)
}
if (ctx.mcp) sections.push(ctx.mcp)
if (!ctx.subagent) sections.push(agentsBlock(ctx, files))
// Memory and skills: snapshots taken when the session started, so the prompt does not change
// under a session (Hermes' frozen snapshot).
// Memory and skills: as they were when the prompt was made, so the prompt does not change under
// a session (Hermes' frozen snapshot); the person's later edit to memory goes as a note.
if (ctx.memory !== undefined && !ctx.subagent) {
sections.push(promptText("blocks/memory.md"))
if (ctx.memory) sections.push(ctx.memory)
@@ -305,6 +347,8 @@ export function assembleSystem(ctx: PromptContext): string {
else if (ctx.manageSkills && !ctx.subagent) sections.push(promptText("blocks/skills-empty.md"))
// The library and prototypes (shared blocks): the library whenever its tools are offered, named
// to the bases in scope; prototypes only where they are offered, with where the page is seen.
// How tool results are kept short (ids, stubs, recall): wherever recall is offered.
if (ctx.context) sections.push(promptText("blocks/context.md"))
if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
@@ -317,8 +361,7 @@ export function assembleSystem(ctx: PromptContext): string {
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
}),
)
else sections.push(fillPrompt(promptText(`modes/${ctx.mode}.md`), vars))
if (ctx.unattended) sections.push(fillPrompt(promptText("modes/unattended.md"), { ...vars, refused: ctx.unattended }))
else sections.push(modeText(ctx.mode, ctx.planDir, ctx.unattended))
// What the person said about themselves goes last, under the web UI's headings and in its order.
// Not for a subagent: it answers the main agent, not the person.
if (ctx.userInstructions && !ctx.subagent) sections.push(`## How the person you are talking to wants to be helped\n\n${ctx.userInstructions}`)
@@ -326,16 +369,28 @@ export function assembleSystem(ctx: PromptContext): string {
return sections.join("\n\n")
}
/** A permission mode's text (modes/<mode>.md), with the unattended block when nobody can approve
* (`refused`: what will be refused, in words): the prompt's own as it was made, and the body of a
* note when the mode changes later (session/prefix.ts). */
export function modeText(mode: Mode, planDir: string, refused?: string): string {
const own = fillPrompt(promptText(`modes/${mode}.md`), { plan_dir: planDir })
return refused ? `${own}\n\n${fillPrompt(promptText("modes/unattended.md"), { plan_dir: planDir, refused })}` : own
}
const GIT_QUESTION =
"do you commit on your own once a change is finished and checked, only when they ask, or never; and which branch the work goes on (the current one, or a new branch for each task)."
const GIT_QUESTION_NO_REPO = "this folder is not a git repository yet — should it be one, and if so, do you commit on your own once a change is finished and checked, or only when they ask."
/** The project's own AGENTS.md (or CLAUDE.md): make it when missing, settle git when it does not,
* and keep it current. The global ~/.config/lembas/AGENTS.md is not the project's. */
function agentsBlock(ctx: PromptContext, files: { path: string; text: string }[]): string {
const own = files.find((f) => f.path !== join(paths.config, "AGENTS.md"))
* and keep it current. The project's are the chain's files (instructionFiles `chain`), stacked from
* its root down: none of them is missing, and git is settled when any of them settles it. The
* global ~/.config/lembas/AGENTS.md and the configured `instructions` are not the project's. */
function agentsBlock(ctx: PromptContext, files: { path: string; text: string; chain?: true }[]): string {
// Files handed in without the mark (an older caller): anything but the global file, as before.
const marked = files.some((f) => f.chain)
const own = files.filter((f) => (marked ? f.chain : f.path !== join(paths.config, "AGENTS.md")))
const vars = { root: ctx.root, git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO }
if (!own) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
if (!own.length) return fillPrompt(promptText("blocks/agents-missing.md"), vars)
const keep = promptText("blocks/agents-keep.md")
return /^#{1,3}\s*git\b/im.test(own.text) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
return own.some((f) => /^#{1,3}\s*git\b/im.test(f.text)) ? keep : `${fillPrompt(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
}
-228
View File
@@ -1,228 +0,0 @@
// Anthropic Messages (`POST {base}/messages`) — api.anthropic.com, and servers that imitate it
// (vLLM, llama-server). Content blocks both ways; thinking with signatures that must be sent back
// on the next turn; optional prompt-caching breakpoints.
import type { Effort } from "../config/schema.ts"
import { resolveKey } from "../config/load.ts"
import { Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
import { sseJson } from "./sse.ts"
import { estimateTokens } from "./tokens.ts"
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
/** Thinking budgets by effort, when the model's config gives no effort_map. */
export const DEFAULT_BUDGETS: Record<Effort, number> = { minimal: 1024, low: 2048, medium: 8192, high: 16384, xhigh: 32000, max: 48000 }
type Block = Record<string, unknown>
type AMessage = { role: "user" | "assistant"; content: Block[] }
/** Anthropic ids must match ^[a-zA-Z0-9_-]+$; ids from other providers are mapped consistently. */
export const safeId = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") || "call"
export function toAnthropicMessages(messages: Message[], vision: boolean, thinking: boolean): AMessage[] {
const out: AMessage[] = []
const push = (role: AMessage["role"], blocks: Block[]) => {
if (!blocks.length) return
const last = out[out.length - 1]
// Consecutive turns of one role are merged: tool results and a following note are one user turn.
if (last && last.role === role) last.content.push(...blocks)
else out.push({ role, content: blocks })
}
for (const m of messages) {
if (m.role === "user") {
push(
"user",
m.parts.map((p) =>
p.type === "text"
? { type: "text", text: p.text }
: vision
? { type: "image", source: { type: "base64", media_type: p.mime, data: p.data } }
: { type: "text", text: "[image omitted: this model has no vision]" },
),
)
} else if (m.role === "assistant") {
const blocks: Block[] = []
for (const p of m.parts) {
if (p.type === "reasoning") {
// A thinking block can only go back with its signature, and only while thinking is on.
if (!thinking) continue
const redacted = (p.opaque as { redacted?: string } | undefined)?.redacted
if (redacted) blocks.push({ type: "redacted_thinking", data: redacted })
else if (p.signature) blocks.push({ type: "thinking", thinking: p.text, signature: p.signature })
} else if (p.type === "text") {
if (p.text.trim()) blocks.push({ type: "text", text: p.text })
} else {
let input: unknown = {}
try {
input = p.args.trim() ? JSON.parse(p.args) : {}
} catch {
input = { _unparsed: p.args }
}
blocks.push({ type: "tool_use", id: safeId(p.id), name: p.name, input })
}
}
push("assistant", blocks)
} else {
push("user", [{ type: "tool_result", tool_use_id: safeId(m.callId), content: m.content || "(empty)", ...(m.isError ? { is_error: true } : {}) }])
}
}
return out
}
export class AnthropicClient implements Client {
constructor(private m: ResolvedModel) {}
private headers() {
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), { "anthropic-version": "2023-06-01", ...this.m.spec.headers })
}
buildBody(req: ChatRequest): Record<string, unknown> {
const { m } = this
const budget = req.effort ? (m.spec.effort_map?.[req.effort] ?? DEFAULT_BUDGETS[req.effort]) : undefined
let maxTokens = m.spec.max_output ?? 8192
if (budget && maxTokens <= budget) maxTokens = budget + Math.min(8192, maxTokens)
const cache = m.spec.cache === true
const body: Record<string, unknown> = {
model: m.id,
max_tokens: maxTokens,
stream: true,
messages: toAnthropicMessages(req.messages, m.spec.vision === true, !!budget),
...m.connection.body,
...m.spec.body,
}
if (req.system) body.system = cache ? [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }] : req.system
if (req.tools.length && m.spec.tools !== false) {
const tools = req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters }))
if (cache) (tools[tools.length - 1] as Block).cache_control = { type: "ephemeral" }
body.tools = tools
}
if (cache) {
const msgs = body.messages as AMessage[]
const last = msgs[msgs.length - 1]?.content
if (last?.length) last[last.length - 1] = { ...last[last.length - 1], cache_control: { type: "ephemeral" } }
}
if (budget) body.thinking = { type: "enabled", budget_tokens: budget }
else {
// Sampling parameters and extended thinking do not mix on this API.
if (m.spec.temperature !== undefined) body.temperature = m.spec.temperature
if (m.spec.top_p !== undefined) body.top_p = m.spec.top_p
}
return body
}
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
const body = this.buildBody(req)
yield* retrying(() => this.attempt(req, body))
}
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
const { m } = this
const res = await request(
joinUrl(m.connection.base_url, "messages"),
{ method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) },
m.ref,
).catch((e) => {
recordFailure(m.ref, body, e)
throw e
})
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
const out = new Parts()
const banner = new SwapBanner()
type Open = { type: string; text: string; signature?: string; id?: string; name?: string; json: string; data?: string; index: number }
const blocks = new Map<number, Open>()
let toolIndex = 0
let finish = "stop"
let input = 0
let output = 0
let cached: number | undefined
let sawUsage = false
for await (const { data } of sseJson(record(res.body, m.ref, body))) {
const ev = data as any
switch (ev?.type) {
case "message_start": {
const u = ev.message?.usage
if (u) {
sawUsage = true
cached = u.cache_read_input_tokens || undefined
input = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
output = u.output_tokens ?? 0
}
break
}
case "content_block_start": {
const b = ev.content_block ?? {}
blocks.set(ev.index, { type: b.type, text: b.text ?? b.thinking ?? "", id: b.id, name: b.name, json: "", data: b.data, index: ev.index })
if (b.type === "tool_use") yield { type: "tool_call_delta", index: toolIndex, name: b.name, argsDelta: "" }
if (b.type === "text" && b.text) {
out.add("text", b.text)
yield { type: "text", text: b.text }
}
break
}
case "content_block_delta": {
const blk = blocks.get(ev.index)
const d = ev.delta ?? {}
if (!blk) break
if (d.type === "text_delta") {
out.add("text", d.text)
yield { type: "text", text: d.text }
} else if (d.type === "thinking_delta") {
const b = banner.feed(d.thinking ?? "")
if (b.notice) yield { type: "notice", message: b.notice }
if (b.text) {
blk.text += b.text
if (b.text.trim()) out.output = true
yield { type: "reasoning", text: b.text }
}
} else if (d.type === "signature_delta") blk.signature = (blk.signature ?? "") + d.signature
else if (d.type === "input_json_delta") {
blk.json += d.partial_json ?? ""
out.output = true
yield { type: "tool_call_delta", index: toolIndex, argsDelta: d.partial_json ?? "" }
}
break
}
case "content_block_stop": {
const blk = blocks.get(ev.index)
if (!blk) break
if (blk.type === "thinking" && blk.text) out.push({ type: "reasoning", text: blk.text, signature: blk.signature })
else if (blk.type === "redacted_thinking") out.push({ type: "reasoning", text: "", opaque: { redacted: blk.data } })
else if (blk.type === "tool_use") {
const call: ToolCallPart = { type: "tool_call", id: blk.id || `toolu_${toolIndex}`, name: blk.name ?? "", args: blk.json || "{}" }
out.push(call)
toolIndex++
}
blocks.delete(ev.index)
break
}
case "message_delta": {
if (ev.delta?.stop_reason) finish = ev.delta.stop_reason
if (ev.usage?.output_tokens !== undefined) {
sawUsage = true
output = ev.usage.output_tokens
}
break
}
case "error": {
const err = ev.error ?? {}
throw new ProviderError(`${m.ref}: ${err.message ?? JSON.stringify(err)}`, statusOf(err) ?? (err.type === "overloaded_error" ? 529 : undefined), undefined, !out.output)
}
default:
if (ev?.error) throw new ProviderError(`${m.ref}: ${ev.error.message ?? JSON.stringify(ev.error)}`, statusOf(ev.error), undefined, !out.output)
}
}
const message = out.message()
const reason = finish === "max_tokens" ? "length" : finish === "tool_use" || message.parts.some((p) => p.type === "tool_call") ? "tool_calls" : "stop"
yield sawUsage
? { type: "usage", usage: { input, output, cached } }
: { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
yield { type: "finish", reason, message }
}
async listModels(): Promise<DiscoveredModel[]> {
const res = await request(joinUrl(this.m.connection.base_url, "models"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
const j = (await res.json()) as any
return (Array.isArray(j?.data) ? j.data : []).map((x: any) => ({ id: String(x.id), context: x.context_window ?? x.max_input_tokens })).filter((x: DiscoveredModel) => x.id)
}
}
-53
View File
@@ -1,53 +0,0 @@
// A model's context window when the config does not give one. Config always wins; what is found
// here is kept in learned.json. Tried in order: the server's model list; llama-server's /props
// (directly, or through llama-swap's /upstream/<model>/ passthrough); Ollama's /api/show.
import { learned, learnContext } from "./learned.ts"
import { authHeaders, request, tlsFor } from "./http.ts"
import { OllamaClient } from "./ollama.ts"
import type { Client, ResolvedModel } from "./types.ts"
import { resolveKey } from "../config/load.ts"
/** The window to plan with: configured, else learned, else undefined (unknown). */
export function contextWindow(m: ResolvedModel): number | undefined {
return m.spec.context || learned().contexts[m.ref] || undefined
}
export async function discoverContext(m: ResolvedModel, client: Client): Promise<number | undefined> {
if (m.spec.context) return m.spec.context
const known = learned().contexts[m.ref]
if (known) return known
const found = (await fromList(m, client)) ?? (await fromProps(m)) ?? (m.connection.dialect === "ollama" ? await (client as OllamaClient).contextOf(m.id).catch(() => undefined) : undefined)
if (found && found > 0) learnContext(m.ref, found)
return found
}
async function fromList(m: ResolvedModel, client: Client): Promise<number | undefined> {
try {
return (await client.listModels()).find((d) => d.id === m.id)?.context
} catch {
return undefined
}
}
async function fromProps(m: ResolvedModel): Promise<number | undefined> {
const base = m.connection.base_url.replace(/\/+$/, "")
const origin = base.replace(/\/v1$/, "")
const headers = authHeaders(m.connection, resolveKey(m.connectionName, m.connection))
for (const url of [`${origin}/upstream/${encodeURIComponent(m.id)}/props`, `${origin}/props`]) {
try {
const res = await request(url, { headers, timeoutMs: 15_000, tls: tlsFor(m.connection) }, m.ref)
const j = (await res.json()) as any
const n = Number(j?.default_generation_settings?.n_ctx ?? j?.n_ctx)
if (Number.isFinite(n) && n > 0) return n
} catch {}
}
return undefined
}
/** llama-swap and friends: free the connection's memory when the session moves elsewhere. */
export async function unload(m: ResolvedModel): Promise<void> {
if (!m.connection.unload_url) return
try {
await request(m.connection.unload_url, { method: m.connection.unload_method ?? "POST", headers: authHeaders(m.connection, resolveKey(m.connectionName, m.connection)), timeoutMs: 10_000, tls: tlsFor(m.connection) }, m.ref)
} catch {}
}
-316
View File
@@ -1,316 +0,0 @@
// Google Gemini, native (`{base}/models/{id}:streamGenerateContent?alt=sse`), base
// https://generativelanguage.googleapis.com/v1beta. UNTESTED: no Gemini endpoint has been
// available to try it against (see the wiki's Working-notes).
import type { Effort } from "../config/schema.ts"
import { resolveKey } from "../config/load.ts"
import { DEFAULT_BUDGETS } from "./anthropic.ts"
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
import { sseJson } from "./sse.ts"
import { estimateTokens } from "./tokens.ts"
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
type Content = { role: "user" | "model"; parts: Record<string, unknown>[] }
// Checked against Hermes Agent (agent/gemini_native_adapter.py, agent/gemini_schema.py) and
// OpenCode (provider/transform.ts). Still untested against a real Gemini endpoint.
/** Gemini's own id, without an aggregator prefix. */
const bare = (id: string) => id.replace(/^(models|google|gemini)\//i, "")
/** Gemini 3 and later: tool calls and results carry ids, and thinking is a level, not a budget. */
export const isGemini3 = (id: string) => Number(/gemini-(\d+)/i.exec(bare(id))?.[1] ?? 0) >= 3
const LEGACY = /gemini-(?:(?:flash|pro)-)?[12](?:[.-]|$)/i
/** A call replayed without a signature (made by another model, or before signatures existed):
* Google's documented sentinel, without which Gemini 3 refuses the whole history. */
export const SKIP_SIGNATURE = "skip_thought_signature_validator"
/** Thought tokens count against maxOutputTokens, and Gemini's own default is low. */
export const DEFAULT_MAX_OUTPUT = 65535
const PLACEHOLDER = "[The previous response was interrupted before it completed.]"
// ── tool schemas ──────────────────────────────────────────────────────────────────────────────
// v1beta takes plain JSON Schema as `parametersJsonSchema`; only same-document $refs have to be
// inlined. Other API versions take `parameters`, an OpenAPI subset where one unknown key 400s the
// whole request, so that path drops everything outside the subset.
const ALLOWED = new Set(["type", "format", "title", "description", "nullable", "enum", "maxItems", "minItems", "properties", "required", "minProperties", "maxProperties", "minLength", "maxLength", "pattern", "example", "anyOf", "propertyOrdering", "default", "items", "minimum", "maximum"])
const STRUCTURAL: Record<string, string[]> = { array: ["items", "minItems", "maxItems"], object: ["properties", "required", "minProperties", "maxProperties", "propertyOrdering"] }
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v)
export function geminiSchema(s: unknown): Record<string, unknown> {
if (!isObj(s)) return {}
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(s)) {
if (!ALLOWED.has(k)) continue
if (k === "properties") {
if (isObj(v)) out.properties = Object.fromEntries(Object.entries(v).map(([p, sub]) => [p, geminiSchema(sub)]))
} else if (k === "items") out.items = geminiSchema(v)
else if (k === "anyOf") {
if (Array.isArray(v)) out.anyOf = v.filter(isObj).map(geminiSchema)
} else out[k] = v
}
const types = out.type
if (Array.isArray(types)) {
delete out.type
const real = types.filter((t): t is string => typeof t === "string" && t !== "null")
if (!real.length) out.type = types.includes("null") ? "null" : "object"
else if (real.length === 1) out.type = real[0]
else {
// Several types: one branch each, and the structure belongs on the branch it describes.
const moved: Record<string, unknown> = {}
for (const keys of Object.values(STRUCTURAL)) for (const k of keys) if (k in out) (moved[k] = out[k]), delete out[k]
out.anyOf = real.map((t) => geminiSchema({ type: t, ...Object.fromEntries(Object.entries(moved).filter(([k]) => STRUCTURAL[t]?.includes(k))) }))
}
if (types.includes("null")) out.nullable = true
}
// Every enum entry must be a string, whatever the declared type.
if (Array.isArray(out.enum) && (Array.isArray(types) || ["integer", "number", "boolean"].includes(out.type as string))) {
const vals = [...new Set(out.enum.map((e) => (typeof e === "boolean" || (typeof e === "number" && Number.isFinite(e)) ? String(e) : typeof e === "string" ? e : undefined)).filter((e) => e !== undefined))]
if (vals.length) out.enum = vals
else delete out.enum
}
// `required` naming a property that is not there fails the entire request.
if (Array.isArray(out.required)) {
const names = isObj(out.properties) ? out.properties : {}
const req = out.required.filter((r): r is string => typeof r === "string" && r in names)
if (req.length) out.required = req
else delete out.required
}
return out
}
/** For `parameters`: the subset, and never an empty schema. */
export function geminiParameters(s: unknown): Record<string, unknown> {
const out = geminiSchema(s)
return Object.keys(out).length ? out : { type: "object", properties: {} }
}
function inlineRefs(node: unknown, root: Record<string, unknown>, budget: { n: number }, stack: string[] = []): unknown {
if (Array.isArray(node)) return node.map((x) => inlineRefs(x, root, budget, stack))
if (!isObj(node)) return node
const ref = node.$ref
if (typeof ref !== "string") return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, inlineRefs(v, root, budget, stack)]))
if (stack.includes(ref)) throw new Error(`circular $ref ${ref}`)
if (--budget.n < 0) throw new Error("$ref budget exhausted")
if (!ref.startsWith("#/")) throw new Error(`unresolvable $ref ${ref}`)
let target: unknown = root
for (const raw of ref.slice(2).split("/")) {
const part = raw.replace(/~1/g, "/").replace(/~0/g, "~")
target = isObj(target) ? target[part] : undefined
}
if (!isObj(target)) throw new Error(`unresolvable $ref ${ref}`)
const inlined = inlineRefs(target, root, budget, [...stack, ref]) as Record<string, unknown>
// Siblings of a $ref apply alongside it and win.
const { $ref: _, ...siblings } = node
return Object.keys(siblings).length ? { ...inlined, ...(inlineRefs(siblings, root, budget, stack) as object) } : inlined
}
/** For `parametersJsonSchema`: root $schema dropped, local $refs inlined, an object root. A schema
* whose refs cannot all be resolved goes as it is, so the provider names the real problem. */
export function geminiJsonSchema(s: unknown): Record<string, unknown> {
if (!isObj(s) || !Object.keys(s).length) return { type: "object", properties: {} }
const { $schema: _, ...rest } = structuredClone(s)
let out: Record<string, unknown>
try {
out = inlineRefs(rest, rest, { n: 256 }) as Record<string, unknown>
} catch {
return rest
}
delete out.$defs
delete out.definitions
if (!Object.keys(out).length) return { type: "object", properties: {} }
if (out.type === "object" && !("properties" in out)) out.properties = {}
return out
}
/** `parametersJsonSchema` exists only on v1beta. */
export const acceptsJsonSchema = (base: string) => /\/v1beta\/*$/i.test(base.trim())
// ── history ───────────────────────────────────────────────────────────────────────────────────
/** A JSON Schema inside a function response is resolved by Gemini 3, and an unknown $ref 400s. */
const hasRef = (v: unknown): boolean => (Array.isArray(v) ? v.some(hasRef) : isObj(v) ? Object.entries(v).some(([k, x]) => (k === "$ref" && typeof x === "string" && x.startsWith("#/")) || hasRef(x)) : false)
function functionResponse(content: string, isError: boolean | undefined): Record<string, unknown> {
if (isError) return { error: content }
// A JSON object result goes structured; anything else under "output", as Google documents.
if (/^\s*\{/.test(content))
try {
const parsed = JSON.parse(content)
if (isObj(parsed) && !hasRef(parsed)) return parsed
} catch {}
return { output: content }
}
export function toGeminiContents(messages: Message[], vision: boolean, ids = false): Content[] {
const out: Content[] = []
const names = new Map<string, string>()
const results = (c: Content) => c.parts.some((p) => "functionResponse" in p)
const push = (role: Content["role"], parts: Record<string, unknown>[]) => {
if (!parts.length) return
const last = out[out.length - 1]
// Same-role turns must merge (Gemini insists on alternation), except that a person's words are
// never folded into a turn of tool results: Gemini 3 then reads them as part of the result and
// answers with nothing. A placeholder model turn keeps the two apart.
if (last && last.role === role && role === "user" && results(last) !== parts.some((p) => "functionResponse" in p)) out.push({ role: "model", parts: [{ text: PLACEHOLDER }] }, { role, parts })
else if (last && last.role === role) last.parts.push(...parts)
else out.push({ role, parts })
}
for (const m of messages) {
if (m.role === "user")
push(
"user",
m.parts.map((p) => (p.type === "text" ? { text: p.text } : vision ? { inlineData: { mimeType: p.mime, data: p.data } } : { text: "[image omitted: this model has no vision]" })),
)
else if (m.role === "assistant") {
const parts: Record<string, unknown>[] = []
for (const p of m.parts) {
if (p.type === "text" && p.text.trim()) parts.push({ text: p.text })
else if (p.type === "tool_call") {
names.set(p.id, p.name)
let args: unknown = {}
try {
args = p.args.trim() ? JSON.parse(p.args) : {}
} catch {
args = { _raw: p.args }
}
// Gemini 3 wants the thought signature back on the call it came with.
parts.push({ functionCall: { name: p.name, args: isObj(args) ? args : { _value: args }, ...(ids ? { id: p.id } : {}) }, thoughtSignature: p.signature ?? SKIP_SIGNATURE })
}
}
push("model", parts)
} else push("user", [{ functionResponse: { name: names.get(m.callId) ?? m.name, response: functionResponse(m.content, m.isError), ...(ids ? { id: m.callId } : {}) } }])
}
return out
}
// ── thinking ──────────────────────────────────────────────────────────────────────────────────
const LEVELS = ["minimal", "low", "medium", "high"] as const
/** The levels a Gemini 3 model takes (OpenCode's table). */
function levelsOf(id: string): readonly string[] {
const s = id.toLowerCase()
if (s.includes("gemma") || s.includes("flash-image")) return ["minimal", "high"]
if (s.includes("pro-image")) return ["high"]
if (s.includes("flash")) return LEVELS
return ["low", "medium", "high"]
}
export function thinkingConfig(id: string, effort: Effort, map?: Partial<Record<Effort, number>>): Record<string, unknown> {
if (map?.[effort]) return { thinkingBudget: map[effort], includeThoughts: true }
const b = bare(id)
if (LEGACY.test(b)) {
// 2.x takes a budget; 2.5 Pro goes to 32k, the rest to 24k.
const max = /gemini-2[.-]5/i.test(b) && /pro/i.test(b) && !/flash/i.test(b) ? 32768 : 24576
return { thinkingBudget: Math.min(DEFAULT_BUDGETS[effort], max), includeThoughts: true }
}
// 3 and later take a level: the one asked for, else the next one up the model has, else its top.
const i = LEVELS.indexOf(effort as (typeof LEVELS)[number])
const want = i < 0 ? LEVELS.length - 1 : i // xhigh and max: the top
const have = levelsOf(b)
const level = LEVELS.slice(want).find((l) => have.includes(l)) ?? have[have.length - 1]
return { thinkingLevel: level, includeThoughts: true }
}
/** Keys sorted, so a call sent again compares equal to itself (as Hermes does). */
const sortedJson = (v: unknown) => JSON.stringify(v, (_, x) => (isObj(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, x[k]])) : x))
const FINISH: Record<string, string> = { STOP: "stop", MAX_TOKENS: "length", SAFETY: "content_filter", RECITATION: "content_filter", BLOCKLIST: "content_filter", PROHIBITED_CONTENT: "content_filter", SPII: "content_filter" }
export class GeminiClient implements Client {
constructor(private m: ResolvedModel) {}
private headers() {
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
}
buildBody(req: ChatRequest): Record<string, unknown> {
const { m } = this
const generationConfig: Record<string, unknown> = {}
if (m.spec.temperature !== undefined) generationConfig.temperature = m.spec.temperature
generationConfig.maxOutputTokens = m.spec.max_output ?? DEFAULT_MAX_OUTPUT
if (m.spec.top_p !== undefined) generationConfig.topP = m.spec.top_p
if (req.effort) generationConfig.thinkingConfig = thinkingConfig(m.id, req.effort, m.spec.effort_map)
const body: Record<string, unknown> = { contents: toGeminiContents(req.messages, m.spec.vision === true, isGemini3(m.id)), generationConfig, ...m.connection.body, ...m.spec.body }
if (req.system) body.systemInstruction = { parts: [{ text: req.system }] }
if (req.tools.length && m.spec.tools !== false) {
const json = acceptsJsonSchema(m.connection.base_url)
body.tools = [{ functionDeclarations: req.tools.map((t) => ({ name: t.name, description: t.description, ...(json ? { parametersJsonSchema: geminiJsonSchema(t.parameters) } : { parameters: geminiParameters(t.parameters) }) })) }]
}
return body
}
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
const body = this.buildBody(req)
yield* retrying(() => this.attempt(req, body))
}
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
const { m } = this
const url = joinUrl(m.connection.base_url, `models/${encodeURIComponent(m.id)}:streamGenerateContent?alt=sse`)
const res = await request(url, { method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) }, m.ref).catch((e) => {
recordFailure(m.ref, body, e)
throw e
})
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
const out = new Parts()
let finish = "stop"
let usage: StreamEvent | undefined
// One slot per call. Gemini 3 ids its calls; 2.5 does not, restarts part numbering in every
// event and may send a call again whole, so a payload repeating (or extending) a slot's arguments
// is that call and anything else is a new one.
const slots = new Map<string, { index: number; part: ToolCallPart }>()
const slotFor = (fc: any, sig: string, at: number, args: string) => {
if (typeof fc.id === "string" && fc.id) return `id:${fc.id}`
const key = `${at}:${fc.name}:${sig}`
const s = slots.get(key)
if (!s || args.startsWith(s.part.args)) return key
for (const [k, o] of slots) if (k.startsWith(`${key}#`) && args.startsWith(o.part.args)) return k
return `${key}#${slots.size}`
}
for await (const { data } of sseJson(record(res.body, m.ref, body))) {
const chunk = data as any
if (chunk?.error) throw new ProviderError(`${m.ref}: ${chunk.error.message ?? JSON.stringify(chunk.error)}`, statusOf(chunk.error) ?? chunk.error.code, undefined, !out.output)
const cand = chunk?.candidates?.[0]
const parts: any[] = cand?.content?.parts ?? []
for (const [at, p] of parts.entries()) {
if (typeof p.text === "string" && p.text) {
const kind = p.thought === true ? "reasoning" : "text"
out.add(kind, p.text)
yield { type: kind, text: p.text }
}
const fc = p.functionCall
if (fc?.name) {
const args = sortedJson(fc.args ?? {})
const sig = typeof p.thoughtSignature === "string" ? p.thoughtSignature : ""
const key = slotFor(fc, sig, at, args)
let s = slots.get(key)
if (!s) {
s = { index: slots.size, part: { type: "tool_call", id: typeof fc.id === "string" && fc.id ? fc.id : `gemini_call_${slots.size}`, name: fc.name, args: "" } }
slots.set(key, s)
out.push(s.part)
}
const prev = s.part.args
s.part.args = args
if (sig) s.part.signature = sig
yield { type: "tool_call_delta", index: s.index, name: fc.name, argsDelta: args.startsWith(prev) ? args.slice(prev.length) : args }
}
}
if (cand?.finishReason) finish = FINISH[String(cand.finishReason).toUpperCase()] ?? "stop"
const u = chunk?.usageMetadata
if (u) usage = { type: "usage", usage: { input: u.promptTokenCount ?? 0, output: (u.candidatesTokenCount ?? 0) + (u.thoughtsTokenCount ?? 0), reasoning: u.thoughtsTokenCount, cached: u.cachedContentTokenCount } }
}
const message = out.message()
if (slots.size && finish !== "length") finish = "tool_calls"
yield usage ?? { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.contents)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
yield { type: "finish", reason: finish, message }
}
async listModels(): Promise<DiscoveredModel[]> {
const res = await request(joinUrl(this.m.connection.base_url, "models?pageSize=200"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
const j = (await res.json()) as any
return (j?.models ?? [])
.filter((x: any) => !x.supportedGenerationMethods || x.supportedGenerationMethods.includes("generateContent"))
.map((x: any) => ({ id: String(x.name ?? "").replace(/^models\//, ""), context: x.inputTokenLimit }))
.filter((x: DiscoveredModel) => x.id)
}
}
+3 -13
View File
@@ -27,20 +27,10 @@ export function tlsFor(c: Pick<Connection, "tls">): { ca?: string; rejectUnautho
return out
}
const DEFAULT_AUTH: Record<Connection["dialect"], NonNullable<Connection["auth"]>> = {
"openai-chat": "bearer",
responses: "bearer",
anthropic: "x-api-key",
gemini: "x-goog-api-key",
ollama: "bearer",
}
/** Auth plus the connection's and model's own headers. */
export function authHeaders(c: Connection, key: string | undefined, extra: Record<string, string> = {}): Record<string, string> {
/** The server's token as a bearer, plus the connection's and model's own headers. */
export function authHeaders(c: Pick<Connection, "headers">, key: string | undefined, extra: Record<string, string> = {}): Record<string, string> {
const h: Record<string, string> = { "content-type": "application/json", ...extra }
const style = c.auth ?? DEFAULT_AUTH[c.dialect]
if (key && style === "bearer") h.authorization = `Bearer ${key}`
else if (key && style !== "none") h[style] = key
if (key) h.authorization = `Bearer ${key}`
return { ...h, ...c.headers }
}
+28 -46
View File
@@ -1,64 +1,40 @@
import { findRef, modelRefs } from "./refs.ts"
import type { Loaded } from "../config/load.ts"
import { AnthropicClient } from "./anthropic.ts"
import { OpenAIChatClient } from "./openai-chat.ts"
import { ResponsesClient } from "./responses.ts"
import { GeminiClient } from "./gemini.ts"
import { OllamaClient } from "./ollama.ts"
import { ProviderError, type Client, type ResolvedModel } from "./types.ts"
import type { Client, ResolvedModel } from "./types.ts"
export class ModelError extends Error {}
/** `connection/model`. The model part may itself contain slashes (`openrouter/qwen/qwen3-coder`). */
/** What a start without a login says: LLeMbas CLI 2.0 has no model of its own, only the server's. */
export const NOT_LOGGED_IN = "Not logged in to a LLeMbas server. LLeMbas CLI uses the models of the server it is logged in to: lembas login <address> (e.g. lembas login ai.example.org)."
/** A model of the server this machine is logged in to, by its served id (`deepseek/deepseek-flash`)
* or an older form of its name (refs.ts). Without a ref: config.yaml's `model`, else the server's
* default for the account, else its first. */
export function resolveModel(loaded: Loaded, ref: string | undefined): ResolvedModel {
const refs = loaded.refs ?? modelRefs(loaded.connections)
if (!ref) {
// A LLeMbas instance says which model its account starts on; that, before anything else.
const instance = Object.entries(loaded.connections).find(([, c]) => c.webui?.default && c.models[c.webui.default])
if (instance) ref = refs.find((r) => r.connection === instance[0] && r.id === instance[1].webui!.default)?.ref ?? `${instance[0]}/${instance[1].webui!.default}`
const [name, connection] = Object.entries(loaded.connections)[0] ?? []
if (!name || !connection) {
const why = Object.values(loaded.broken ?? {})[0]
throw new ModelError(why ? `The LLeMbas login is unusable: ${why}` : NOT_LOGGED_IN)
}
if (!ref) {
const first = refs[0]
if (!first) throw new ModelError("No model configured. Add a connection to ~/.config/lembas/connections.yaml and set `model:` in config.yaml.")
ref = first.ref
}
// Any form of the name (`deepseek/x`, or the old `example/x`): the model it names, under
// the ref it has now.
if (loaded.broken?.[name]) throw new ModelError(`The LLeMbas login ${name} is unusable: ${loaded.broken[name]}`)
if (!ref) ref = connection.webui?.default && connection.models[connection.webui.default] ? connection.webui.default : refs[0]?.ref
if (!ref) throw new ModelError(`${name} serves no model to this account yet. lembas login ${connection.webui?.url ?? ""} again once it does.`.replace(" ", " "))
const known = findRef(loaded.connections, refs, ref)
if (known) {
const connection = loaded.connections[known.connection]!
if (loaded.broken?.[known.connection]) throw new ModelError(`Connection "${known.connection}" is unusable: ${loaded.broken?.[known.connection]}`)
return { ref: known.ref, connectionName: known.connection, connection, id: known.id, spec: connection.models[known.id] ?? {} }
}
const slash = ref.indexOf("/")
if (slash <= 0) throw new ModelError(`Model "${ref}" must be written connection/model`)
const connectionName = ref.slice(0, slash)
const id = ref.slice(slash + 1)
if (loaded.broken?.[connectionName]) throw new ModelError(`Connection "${connectionName}" is unusable: ${loaded.broken[connectionName]}`)
const connection = loaded.connections[connectionName]
if (!connection) throw new ModelError(`No connection named "${connectionName}" (have: ${Object.keys(loaded.connections).join(", ") || "none"})`)
const spec = connection.models[id]
if (!spec && !connection.discover)
throw new ModelError(`Connection "${connectionName}" has no model "${id}" (have: ${Object.keys(connection.models).join(", ") || "none"}). Add it, or set discover: true.`)
return { ref, connectionName, connection, id, spec: spec ?? {} }
if (known) return { ref: known.ref, connectionName: name, connection, id: known.id, spec: connection.models[known.id] ?? {} }
// A model the server serves that its list did not have when it was read: the server decides
// which models exist, so it is asked for rather than refused here. The login's own name in front
// of it (an older form) is taken off.
const id = ref.startsWith(`${name}/`) ? ref.slice(name.length + 1) : ref
return { ref: id, connectionName: name, connection, id, spec: {} }
}
export function clientFor(m: ResolvedModel): Client {
switch (m.connection.dialect) {
case "openai-chat":
return new OpenAIChatClient(m)
case "anthropic":
return new AnthropicClient(m)
case "responses":
return new ResponsesClient(m)
case "gemini":
return new GeminiClient(m)
case "ollama":
return new OllamaClient(m)
}
return new OpenAIChatClient(m)
}
/** Which prompt family a model gets when the config does not say: anthropic, gpt, gemini, or
/** Which prompt family a model gets when the server does not say: anthropic, gpt, gemini, or
* local — every open-weight or unknown model, which gets Hermes' act-don't-describe guidance. */
export function familyOf(m: ResolvedModel): string {
if (m.spec.family) return m.spec.family
@@ -68,3 +44,9 @@ export function familyOf(m: ResolvedModel): string {
if (/\bgpt|^o[134]\b|^o[134]-|codex/.test(id)) return "gpt"
return "local"
}
/** The window to plan with, as the server gives it; undefined when it does not say (no percentage,
* no automatic compaction). */
export function contextWindow(m: ResolvedModel): number | undefined {
return m.spec.context || undefined
}
+5 -10
View File
@@ -6,14 +6,12 @@ import { paths } from "../config/paths.ts"
// What endpoints have taught us, kept across runs in ~/.local/state/lembas/learned.json.
// Only ever narrows behaviour; deleting the file is always safe.
interface Learned {
/** `connection/model` → the efforts it actually accepts. */
/** A model ref → the efforts it actually accepts. */
efforts: Record<string, Effort[]>
/** base URLs that reject `stream_options`. */
noStreamOptions: string[]
/** base URLs that reject `return_progress`. */
noProgress: string[]
/** `connection/model` → context window found by discovery (config always wins). */
contexts: Record<string, number>
}
let cache: Learned | undefined
@@ -25,9 +23,11 @@ function file() {
export function learned(): Learned {
if (cache) return cache
try {
cache = { efforts: {}, noStreamOptions: [], noProgress: [], contexts: {}, ...JSON.parse(readFileSync(file(), "utf8")) }
// An older file's `contexts` (what discovery found, before 2.0 took the server's word) is dropped.
const { contexts: _, ...kept } = JSON.parse(readFileSync(file(), "utf8")) as Record<string, unknown>
cache = { efforts: {}, noStreamOptions: [], noProgress: [], ...kept } as Learned
} catch {
cache = { efforts: {}, noStreamOptions: [], noProgress: [], contexts: {} }
cache = { efforts: {}, noStreamOptions: [], noProgress: [] }
}
return cache!
}
@@ -46,11 +46,6 @@ export function learnEfforts(ref: string, efforts: Effort[]) {
save()
}
export function learnContext(ref: string, n: number) {
learned().contexts[ref] = n
save()
}
export function learnNoStreamOptions(baseUrl: string) {
const l = learned()
if (!l.noStreamOptions.includes(baseUrl)) l.noStreamOptions.push(baseUrl)
-178
View File
@@ -1,178 +0,0 @@
// Ollama, native (`POST {base}/api/chat`, NDJSON), base e.g. http://localhost:11434. Its
// OpenAI-compatible /v1 works through openai-chat too; the native API adds num_ctx (without it
// Ollama silently cuts every conversation to its small default window) and `think` levels.
// UNTESTED: no Ollama server has been available to try it against (see the wiki). Checked against
// Hermes Agent's handling (agent/model_metadata.py, agent/reasoning_params.py): /api/show decides
// the window to ask for and whether `think` may be sent at all.
import { resolveKey } from "../config/load.ts"
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
import { estimateTokens } from "./tokens.ts"
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent } from "./types.ts"
export function toOllamaMessages(system: string, messages: Message[], vision: boolean): Record<string, unknown>[] {
const out: Record<string, unknown>[] = []
if (system) out.push({ role: "system", content: system })
for (const m of messages) {
if (m.role === "user") {
const images = vision ? m.parts.filter((p) => p.type === "image").map((p) => (p as { data: string }).data) : []
const text = m.parts.map((p) => (p.type === "text" ? p.text : vision ? "" : "[image omitted: this model has no vision]")).filter(Boolean).join("\n")
out.push({ role: "user", content: text, ...(images.length ? { images } : {}) })
} else if (m.role === "assistant") {
const text = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")
const thinking = m.parts.map((p) => (p.type === "reasoning" ? p.text : "")).join("")
const calls = m.parts.flatMap((p) => {
if (p.type !== "tool_call") return []
let args: unknown = {}
try {
args = p.args.trim() ? JSON.parse(p.args) : {}
} catch {}
return [{ function: { name: p.name, arguments: args } }]
})
out.push({ role: "assistant", content: text, ...(thinking ? { thinking } : {}), ...(calls.length ? { tool_calls: calls } : {}) })
} else out.push({ role: "tool", content: m.content, tool_name: m.name })
}
return out
}
/** NDJSON: one JSON object per line. */
async function* ndjson(body: ReadableStream<Uint8Array>): AsyncGenerator<unknown> {
const dec = new TextDecoder()
let buf = ""
for await (const chunk of body) {
buf += dec.decode(chunk, { stream: true })
let nl: number
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim()
buf = buf.slice(nl + 1)
if (line)
try {
yield JSON.parse(line)
} catch {}
}
}
const last = (buf + dec.decode()).trim()
if (last)
try {
yield JSON.parse(last)
} catch {}
}
/** What /api/show says about a model. */
export interface OllamaShow {
/** "completion", "tools", "thinking", "vision"…; absent before Ollama 0.6. */
capabilities?: string[]
/** The runtime window the Modelfile sets (`num_ctx`), which the owner of the server chose. */
numCtx?: number
/** The trained maximum (`model_info["<arch>.context_length"]`). */
trained?: number
}
export function parseShow(j: any): OllamaShow {
const out: OllamaShow = {}
if (Array.isArray(j?.capabilities)) out.capabilities = j.capabilities.map(String)
for (const line of String(j?.parameters ?? "").split("\n")) {
const f = line.trim().split(/\s+/)
if (f[0] === "num_ctx" && Number(f[1]) > 0) out.numCtx = Number(f[1])
}
const info = j?.model_info ?? {}
const key = Object.keys(info).find((k) => k.endsWith(".context_length"))
if (key && Number(info[key]) > 0) out.trained = Number(info[key])
return out
}
export class OllamaClient implements Client {
private shown?: Promise<OllamaShow | undefined>
constructor(private m: ResolvedModel) {}
/** /api/show for this model, asked once; undefined when the server does not answer it. */
show(id = this.m.id): Promise<OllamaShow | undefined> {
const ask = async () => {
const res = await request(joinUrl(this.m.connection.base_url, "api/show"), { method: "POST", headers: this.headers(), body: JSON.stringify({ model: id, name: id }), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
return parseShow(await res.json())
}
if (id !== this.m.id) return ask().catch(() => undefined)
return (this.shown ??= ask().catch(() => undefined))
}
private headers() {
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
}
buildBody(req: ChatRequest, show?: OllamaShow): Record<string, unknown> {
const { m } = this
const options: Record<string, unknown> = {}
// Without num_ctx Ollama cuts every conversation to its small default window. Configured wins;
// else the Modelfile's, else the trained maximum (set `context` to cap it to what memory holds).
const ctx = m.spec.context ?? show?.numCtx ?? show?.trained
if (ctx) options.num_ctx = ctx
if (m.spec.max_output) options.num_predict = m.spec.max_output
if (m.spec.temperature !== undefined) options.temperature = m.spec.temperature
if (m.spec.top_p !== undefined) options.top_p = m.spec.top_p
const body: Record<string, unknown> = { model: m.id, messages: toOllamaMessages(req.system, req.messages, m.spec.vision === true), stream: true, options, ...m.connection.body, ...m.spec.body }
if (req.tools.length && m.spec.tools !== false)
body.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }))
// gpt-oss takes a level; other thinking models take a switch. A model /api/show says cannot
// think gets no `think` at all: Ollama refuses the request.
const thinks = !show?.capabilities || show.capabilities.includes("thinking")
if (thinks && req.effort) body.think = /gpt-oss/.test(m.id) ? (req.effort === "minimal" ? "low" : req.effort === "xhigh" || req.effort === "max" ? "high" : req.effort) : true
else if (thinks && m.spec.efforts?.length) body.think = false
return body
}
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
const body = this.buildBody(req, await this.show())
yield* retrying(() => this.attempt(req, body))
}
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
const { m } = this
const res = await request(joinUrl(m.connection.base_url, "api/chat"), { method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) }, m.ref).catch((e) => {
recordFailure(m.ref, body, e)
throw e
})
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
const out = new Parts()
let finish = "stop"
let usage: StreamEvent | undefined
let calls = 0
for await (const chunk of ndjson(record(res.body, m.ref, body)) as AsyncGenerator<any>) {
if (chunk?.error) throw new ProviderError(`${m.ref}: ${chunk.error}`, statusOf(chunk) ?? 500, undefined, !out.output)
const msg = chunk?.message ?? {}
if (msg.thinking) {
out.add("reasoning", msg.thinking)
yield { type: "reasoning", text: msg.thinking }
}
if (msg.content) {
out.add("text", msg.content)
yield { type: "text", text: msg.content }
}
for (const tc of msg.tool_calls ?? []) {
const args = JSON.stringify(tc.function?.arguments ?? {})
yield { type: "tool_call_delta", index: calls, name: tc.function?.name, argsDelta: args }
out.push({ type: "tool_call", id: tc.id ?? `ollama_call_${calls}`, name: tc.function?.name ?? "", args })
calls++
}
if (chunk?.done) {
finish = chunk.done_reason === "length" ? "length" : "stop"
if (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined) usage = { type: "usage", usage: { input: chunk.prompt_eval_count ?? 0, output: chunk.eval_count ?? 0 } }
}
}
const message = out.message()
if (calls && finish !== "length") finish = "tool_calls"
yield usage ?? { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
yield { type: "finish", reason: finish, message }
}
async listModels(): Promise<DiscoveredModel[]> {
const res = await request(joinUrl(this.m.connection.base_url, "api/tags"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
const j = (await res.json()) as any
return (j?.models ?? []).map((x: any) => ({ id: String(x.name ?? x.model ?? "") })).filter((x: DiscoveredModel) => x.id)
}
/** The model's window: the Modelfile's num_ctx, else its trained maximum. */
async contextOf(id: string): Promise<number | undefined> {
const s = await this.show(id)
return s?.numCtx ?? s?.trained
}
}
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more