Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a97c5b46a4
|
||
|
|
aa83bb9783
|
||
|
|
8e06590d57
|
||
|
|
ecae6247fd
|
||
|
|
0d04c5300f
|
||
|
|
a029ee6f07
|
||
|
|
059d34cec1
|
||
|
|
559b521ca9
|
||
|
|
ef9dcf33c9
|
||
|
|
b1dcaded72
|
||
|
|
4ca6af12b1
|
||
|
|
eacf84fd67
|
||
|
|
89ceeec159
|
||
|
|
77161bee35
|
||
|
|
ef209f41a1
|
||
|
|
d5043ab0db
|
||
|
|
a73adc01f0
|
||
|
|
6d5a25cdad
|
||
|
|
28ff205ed0
|
||
|
|
f20e8c4307
|
||
|
|
8cfbfbbca4
|
||
|
|
e7300b26d5
|
||
|
|
4bbee18cee
|
||
|
|
bc78aec424
|
||
|
|
9f536d8345
|
||
|
|
6ca43b6908
|
||
|
|
f606c5d1e7
|
||
|
|
253ef4a8a0
|
No files matched your search
+92
-1
@@ -4,7 +4,98 @@ All notable changes to LLeMbas CLI. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||||
[Semantic Versioning](https://semver.org/).
|
[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
|
## [1.5.2] — 2026-10-25
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,11 @@
|
|||||||
|
|
||||||
# LLeMbas CLI
|
# LLeMbas CLI
|
||||||
|
|
||||||
A terminal coding agent and project manager: the `lembas` command. It talks to any LLM API
|
A terminal coding agent and project manager: the `lembas` command. It signs in to a
|
||||||
(OpenAI-compatible servers such as llama.cpp, vLLM or LM Studio, the OpenAI Responses API,
|
[LLeMbas](https://github.com/LLeMbas/LLeMbas) server and uses its models, memory and search, works
|
||||||
Anthropic, Gemini, Ollama), works in your project with four permission modes and a git snapshot of
|
in your project with four permission modes and a git snapshot of every turn, keeps its own notes,
|
||||||
every turn, remembers what it learns across sessions, and can sign in to a
|
knowledge bases and skills on your machine, and lets the server's web UI work the same sessions.
|
||||||
[LLeMbas](https://github.com/LLeMbas/LLeMbas) server to use its models, library and search — and
|
Linux, x64 and arm64.
|
||||||
let its web UI work the same sessions. Linux, x64 and arm64.
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -24,12 +23,16 @@ checksum before installing anything ([SECURITY.md](SECURITY.md) shows how to che
|
|||||||
## First steps
|
## First steps
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd your-project && lembas # the TUI
|
lembas login https://your-server # sign in to your LLeMbas server
|
||||||
lembas login https://your-server # sign in to a LLeMbas server and use its models
|
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
|
A program that already holds an agent's token for this machine (LLeMbas Desktop does, from the
|
||||||
`~/.config/lembas/config.yaml`.
|
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
|
`lembas` updates itself (`lembas update` does it now); `lembas uninstall` removes it, and
|
||||||
`--purge` takes settings and sessions too.
|
`--purge` takes settings and sessions too.
|
||||||
|
|||||||
+106
-7
@@ -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.
|
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
|
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
|
## 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 |
|
| `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 |
|
| `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 |
|
| `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/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/arity.json` | How much of a command an "always allow" covers |
|
||||||
| `permission/defaults.json` | The rules every session starts from |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `quirks.json` | What real servers do, and how both harnesses cope |
|
||||||
| `conformance/<area>.json` | Cases with their expected results, run by both test suites |
|
| `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 |
|
| `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
|
- **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
|
two names key what is per project elsewhere: a tool's `aliases`, `loop.json`'s budgets, a
|
||||||
protected path's `scope`.
|
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
|
- 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:
|
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`,
|
**`todo` → `plan_write`**, `todos[]` becoming one phase called "Work" (`pending` → `todo`,
|
||||||
`in_progress` → `doing`, `completed` → `done`, `cancelled` → `dropped`; `conformance/plan.json`).
|
`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 —
|
- **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
|
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
|
implementation and never by the model — shown as a card in every UI. From 3.1.0 it is in the
|
||||||
(`{{plan_context}}`) on every step while it exists. `plan_write` and `plan_update` are offered in
|
system prompt (`{{plan_context}}`) only as it was when the prompt was made; the model sees it change
|
||||||
every mode; `plan_submit` only in plan mode, where it is the approval. Their permission key is
|
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.
|
`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
|
## 2.0.0
|
||||||
|
|
||||||
Background work is one thing in both projects: a **job**, owned by a session, is a command run in
|
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
|
## Changing it
|
||||||
|
|
||||||
1. Change the spec first, and bump `VERSION`: **patch** for a text or a new conformance case,
|
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
|
into another, its name kept as an alias that still runs, is minor: nothing stored or trained
|
||||||
breaks.
|
breaks.
|
||||||
2. A fix comes with a **conformance case** that fails without it. In LLeMbas CLI,
|
2. A fix comes with a **conformance case** that fails without it. In LLeMbas CLI,
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.0.0
|
3.1.0
|
||||||
+102
-55
@@ -13,24 +13,31 @@ change to the protocol is a change here first, then in both projects.
|
|||||||
## Versions and discovery
|
## Versions and discovery
|
||||||
|
|
||||||
- **`protocol`** (`_meta.lembas.protocol` in `initialize`, `protocol` in `_lembas/hello`):
|
- **`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`.
|
`services/device_link.py:PROTOCOL`.
|
||||||
- **Discovery** (`/.well-known/lembas.json`) says `protocol: 1` **and** `protocols: [1, 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
|
||||||
`protocol` stays 1 on purpose, because a client that compares it for equality at login must
|
the server's file system, memory and models, and a 2.0 CLI talks to nothing else, so a 1.x peer
|
||||||
still get in. The CLI reads `protocols` (falling back to `protocol` where it is absent) at
|
and a 2.0 one **refuse each other**, each saying what to update:
|
||||||
login and again **every time the link dials**, keeps it with the login, and says protocol 2 in
|
- **Discovery** (`/.well-known/lembas.json`) of a 2.0 server says `protocol: 3` **and**
|
||||||
`hello` and `initialize` only to an instance that lists 2; to any other it says 1 (an instance that
|
`protocols: [3]`. `protocol` was frozen at 1 for 1.x so that a client comparing it for equality
|
||||||
speaks only 1 closes a link whose hello says 2 with **4400**). A link closed with 4400 stops — it is not
|
could still log in; 3 raises it on purpose, so a 1.x CLI's login is refused there (it says the
|
||||||
redialled — saying *the instance and this CLI speak different link protocols — update one of them*.
|
server speaks protocol 3 and to update the older of the two).
|
||||||
- **A protocol 1 peer keeps working.** Every feature beyond protocol 1 is named by a **capability
|
- A **2.0 CLI** logs in only where discovery lists 3 (`protocols`, falling back to `protocol`).
|
||||||
string** in `initialize`'s `_meta.lembas.capabilities`, and **LLeMbas never calls a method, nor
|
To a server that does not, at login and again at every dial of the link, it says *<server>
|
||||||
relies on a field, the device did not list**. The CLI logs in to an instance whose discovery says
|
runs LLeMbas X, which speaks link protocol 1 and 2; this LLeMbas CLI needs a LLeMbas 2.0 server
|
||||||
protocol 1 or 2.
|
(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
|
- 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`).
|
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
|
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
|
`_lembas/question` and `_lembas/plan` (a 2.0 server says 3), and one that then answers them with
|
||||||
treated as protocol 1 for the rest of the link.
|
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 |
|
| 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` |
|
| `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`) |
|
| `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 |
|
| `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` |
|
| `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 |
|
| `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` |
|
| `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` |
|
| `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 |
|
| `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
|
## Standard ACP, as used here
|
||||||
|
|
||||||
### `initialize` — client → agent
|
### `initialize` — client → agent
|
||||||
|
|
||||||
Params: `{ protocolVersion: 1, clientCapabilities: { _meta?: { lembas?: { protocol: 2 } | true } } }`.
|
Params: `{ protocolVersion: 1, clientCapabilities: { _meta?: { lembas?: { protocol: 3, show?: true } | true } } }`.
|
||||||
|
|
||||||
Result:
|
Result:
|
||||||
|
|
||||||
@@ -75,10 +83,10 @@ Result:
|
|||||||
"mcpCapabilities": { "http": false, "sse": false }
|
"mcpCapabilities": { "http": false, "sse": false }
|
||||||
},
|
},
|
||||||
"authMethods": [],
|
"authMethods": [],
|
||||||
"agentInfo": { "name": "LLeMbas CLI", "version": "1.3.0" },
|
"agentInfo": { "name": "LLeMbas CLI", "version": "2.0.0" },
|
||||||
"_meta": { "lembas": {
|
"_meta": { "lembas": {
|
||||||
"protocol": 2,
|
"protocol": 3,
|
||||||
"harness_spec": "1.1.0",
|
"harness_spec": "3.0.0",
|
||||||
"limits": { "roots": ["~/work"], "max_mode": "edit" },
|
"limits": { "roots": ["~/work"], "max_mode": "edit" },
|
||||||
"connection": "example",
|
"connection": "example",
|
||||||
"capabilities": ["directories", "configure", "…"]
|
"capabilities": ["directories", "configure", "…"]
|
||||||
@@ -87,32 +95,39 @@ Result:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`limits` is `null` on `serve --stdio`. `connection` is the device's name for the instance — the
|
`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
|
login's connection — or `null`; the `model` event and `announce` say directly whether a model is
|
||||||
models by it, as `<connection>/<name>`, while the `model` event and `announce` say it directly
|
the instance's (`instance`).
|
||||||
(`instance`).
|
|
||||||
|
|
||||||
**How the device names an instance's models.** An instance that speaks protocol 2 serves every model
|
**How the device names an instance's models.** The served id **is the device's ref** for a model
|
||||||
as `<provider>/<model>` (`deepseek/deepseek-flash`), and that served id **is the device's ref** for
|
(`deepseek/deepseek-flash`; spec 3.0.0: a 2.0 device talks to one server, the one it is logged in
|
||||||
it — each provider is, as the person sees it, a connection of its own, backed by the instance
|
to, so there is nothing to tell apart — no connection prefix, no second login, no connection of
|
||||||
(requests still go to its `/v1`, with the served id). Exceptions: a connection of the device's own
|
the device's own). The older forms (`<login>/<id>`, and from before providers `<login>/<model>` for
|
||||||
with the provider's name keeps the short form, and the instance's model is then
|
the one served `<provider>/<model>` of that name) still name the same model. `lembas_model` in
|
||||||
`<login>/<provider>/<model>`; with two instances serving one provider, the one logged in to first
|
`session/new` and `model` in `_lembas/configure` are the instance's served name, as it serves it.
|
||||||
keeps the short form and the other's are `<login>/<provider>/<model>`; an instance that does not
|
`model` in a `model` event and in a result is the device's ref, which is the served id.
|
||||||
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.
|
|
||||||
|
|
||||||
### `session/new` — client → agent
|
### `session/new` — client → agent
|
||||||
|
|
||||||
Params: `{ cwd: <absolute>, mcpServers?: [], _meta?: { mode?, lembas_model?, model?, effort?, lembas?: Context } }`.
|
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`
|
`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`.
|
(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`
|
`lembas_model` is the instance's name of a model; `model` a ref the device knows (an editor's
|
||||||
a ref of the device's own; `effort` a level or `"off"`.
|
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
|
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`,
|
is opened from the store under the same rules (capability `sessions`). With capability `commands`,
|
||||||
an `available_commands_update` follows the answer.
|
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
|
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.
|
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
|
### `_lembas/session/compact` — client → agent → result
|
||||||
|
|
||||||
Params `{ sessionId }`. Result `{ summary }`. `-32602` while it works.
|
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;
|
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
|
`embedding: <served id>` also finds it. Voice uses the instance's own speech endpoints, not these
|
||||||
entries. Any other kind is ignored.
|
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`** →
|
- **`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" }`.
|
`{ month: { from, tokens_in, tokens_out, cost_money: number | null, cost_by_currency: { [currency]: number } }, device: { tokens_in, tokens_out }, currency, admin, unit: "money" }`.
|
||||||
The CLI shows the balance in its status bar when `plan` is not null, and the whole of it under
|
Counting and money, no credits (spec 3.0.0): `cost_by_currency` is the month's cost in each
|
||||||
`/usage`. A 404 (an instance without it) shows nothing.
|
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
|
||||||
Also read, every one optional (an instance without them gets credits alone):
|
currency is involved, in `currency`; with several it is `null`. `plan`, `balance`,
|
||||||
`currency` (`"EUR"` | `"USD"` | `"GBP"`), `credit_value` (money per credit), `admin` (boolean:
|
`balance_money`, `month.cost`, `device.cost` and `device.cost_money` are still sent, as `null`,
|
||||||
no limit, never refused), `balance_money`, `month.cost_money` and `device.cost_money` (numbers or
|
and `windows` as `[]`, for a 1.x client; a 3.0.0 client does not read them. The CLI shows the
|
||||||
`null`), and `windows: [{ window, limit, spent, frees_at }]` (rolling spending windows: `window`
|
month's cost (each currency's amount, `€2.00 · $0.75`) in its status bar when anything was
|
||||||
names it, `limit` and `spent` in credits, `frees_at` an ISO time or `null`). Credits are shown
|
priced, and the whole of it under `/usage`. A 404 (an instance without it) shows nothing.
|
||||||
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.
|
|
||||||
- **`GET /v1/me/personalization`** →
|
- **`GET /v1/me/personalization`** →
|
||||||
`{ enabled, available, personality, personality_custom, instructions }`; **`PUT`** the same fields
|
`{ 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
|
(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
|
`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
|
the instance keeps what it has saved. LLeMbas must accept a partial `PUT`. Logged out, the local
|
||||||
keys apply.
|
keys apply.
|
||||||
- **The library**: logged in to one instance whose token has the `library` scope, a config that sets
|
- **The library**: logged in to one instance whose token has the `memory` scope (or the older
|
||||||
no `library:` uses the account's (`lembas`); `library: local` written stays local.
|
`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,
|
**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
|
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
|
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
|
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.
|
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
|
## Notes
|
||||||
|
|
||||||
- `_lembas/files/search`'s `root` is the session's working directory, which `@path` mentions
|
- `_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
|
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
|
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.
|
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,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": [
|
"cases": [
|
||||||
{
|
{
|
||||||
"size": 200,
|
"size": 200,
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 |"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"wrap_up": {
|
"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."
|
"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": {
|
"nudges": {
|
||||||
@@ -60,5 +60,68 @@
|
|||||||
"default": 50
|
"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."
|
"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,6 +11,10 @@
|
|||||||
"glob": "allow",
|
"glob": "allow",
|
||||||
"grep": "allow",
|
"grep": "allow",
|
||||||
"list": "allow",
|
"list": "allow",
|
||||||
|
"search": "allow",
|
||||||
|
"recall": "allow",
|
||||||
|
"show": "allow",
|
||||||
|
"python": "allow",
|
||||||
"ask_user": "allow",
|
"ask_user": "allow",
|
||||||
"plan_submit": "allow",
|
"plan_submit": "allow",
|
||||||
"task": "allow",
|
"task": "allow",
|
||||||
@@ -74,6 +78,12 @@
|
|||||||
"tree *-o*": "ask",
|
"tree *-o*": "ask",
|
||||||
"tree *-R*": "ask",
|
"tree *-R*": "ask",
|
||||||
"file *-C*": "ask"
|
"file *-C*": "ask"
|
||||||
}
|
},
|
||||||
|
"commit": "ask",
|
||||||
|
"branch": "ask",
|
||||||
|
"pr": "ask",
|
||||||
|
"merge": "ask",
|
||||||
|
"tag": "ask",
|
||||||
|
"release": "ask"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.",
|
"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" },
|
"aliases": { "unrestricted": "auto" },
|
||||||
"floor": "The hardline (hardline.json) and the protected paths are refused in every mode, auto included.",
|
"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": {
|
"modes": {
|
||||||
"manual": {
|
"manual": {
|
||||||
"summary": "Everything not already allowed is asked.",
|
"summary": "Everything not already allowed is asked.",
|
||||||
@@ -31,7 +32,8 @@
|
|||||||
"read": "rules",
|
"read": "rules",
|
||||||
"write": "allow only for files under the plans directory; anything else is refused",
|
"write": "allow only for files under the plans directory; anything else is refused",
|
||||||
"execute": "only what the rules already allow; 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)."
|
"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)."
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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)",
|
"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)",
|
"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",
|
"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_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",
|
"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",
|
"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"
|
"llembas_text": "identical"
|
||||||
},
|
},
|
||||||
"blocks/prototypes.md": {
|
"blocks/prototypes.md": {
|
||||||
"scope": "shared",
|
"scope": "cli",
|
||||||
"llembas": [
|
"llembas": [],
|
||||||
"core.prototypes"
|
|
||||||
],
|
|
||||||
"llembas_text": "identical",
|
|
||||||
"variables": {
|
"variables": {
|
||||||
"prototype_sources": "whether the page may load anything over https (the instance's network setting), as one sentence",
|
"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"
|
"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": {
|
"blocks/library.md": {
|
||||||
"scope": "shared",
|
"scope": "cli",
|
||||||
"llembas": [
|
"llembas": [],
|
||||||
"core.library"
|
|
||||||
],
|
|
||||||
"llembas_text": "identical",
|
|
||||||
"variables": {
|
"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"
|
"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": {
|
"blocks/subagent.md": {
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
@@ -227,6 +223,23 @@
|
|||||||
"llembas": [],
|
"llembas": [],
|
||||||
"llembas_text": "identical",
|
"llembas_text": "identical",
|
||||||
"why": "LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,4 @@ Leave out pleasantries, retracted ideas and anything already superseded. Do not
|
|||||||
|
|
||||||
{{previous_summary}}
|
{{previous_summary}}
|
||||||
|
|
||||||
## Transcript
|
|
||||||
|
|
||||||
{{transcript}}
|
{{transcript}}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "apply_patch",
|
"name": "apply_patch",
|
||||||
"scope": "cli",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "write",
|
"risk": "write",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "edit",
|
"block": "edit",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ask_user",
|
"name": "ask_user",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": true,
|
"exclusive": true,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "bash",
|
"name": "bash",
|
||||||
"scope": "cli",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "execute",
|
"risk": "execute",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "shell",
|
"block": "shell",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "decisions",
|
"name": "decisions",
|
||||||
"scope": "cli",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "edit",
|
"name": "edit",
|
||||||
"scope": "cli",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "write",
|
"risk": "write",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "edit",
|
"block": "edit",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "glob",
|
"name": "glob",
|
||||||
"scope": "cli",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "search",
|
"block": "search",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "grep",
|
"name": "grep",
|
||||||
"scope": "cli",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "search",
|
"block": "search",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "job_list",
|
"name": "job_list",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "job_output",
|
"name": "job_output",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "job_send",
|
"name": "job_send",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "job_stop",
|
"name": "job_stop",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "execute",
|
"risk": "execute",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "knowledge_get",
|
"name": "knowledge_get",
|
||||||
"scope": "shared",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "read",
|
"block": "read",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "knowledge_search",
|
"name": "knowledge_search",
|
||||||
"scope": "shared",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "search",
|
"block": "search",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "list",
|
"name": "list",
|
||||||
"scope": "cli",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "search",
|
"block": "search",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "memory",
|
"name": "memory",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "multiedit",
|
"name": "multiedit",
|
||||||
"scope": "cli",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "write",
|
"risk": "write",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "edit",
|
"block": "edit",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "note_manage",
|
"name": "note_manage",
|
||||||
"scope": "shared",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
@@ -53,12 +54,5 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"action"
|
"action"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"narrow": {
|
|
||||||
"llembas": {
|
|
||||||
"drop": [
|
|
||||||
"scope"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "note_view",
|
"name": "note_view",
|
||||||
"scope": "shared",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "read",
|
"block": "read",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notes_search",
|
"name": "notes_search",
|
||||||
"scope": "shared",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "search",
|
"block": "search",
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "plan_submit",
|
"name": "plan_submit",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": true,
|
"exclusive": true,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
"purpose": false,
|
"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": {
|
"aliases": {
|
||||||
"cli": [
|
"cli": [
|
||||||
"plan_exit"
|
"plan_exit"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "plan_update",
|
"name": "plan_update",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "plan",
|
"block": "plan",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "plan_write",
|
"name": "plan_write",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "plan",
|
"block": "plan",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "read",
|
"name": "read",
|
||||||
"scope": "cli",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "read",
|
"block": "read",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "report",
|
"name": "report",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "session_search",
|
"name": "session_search",
|
||||||
"scope": "cli",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "search",
|
"block": "search",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "settings",
|
"name": "settings",
|
||||||
"scope": "cli",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "skill_manage",
|
"name": "skill_manage",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "write",
|
"risk": "write",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string",
|
"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": {
|
||||||
"description": "create: when to use the skill, one sentence, the trigger first: \"Use when …\"",
|
"description": "create: when to use the skill, one sentence, the trigger first: \"Use when …\"",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "skill_view",
|
"name": "skill_view",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "read",
|
"block": "read",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "skills_list",
|
"name": "skills_list",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "search",
|
"block": "search",
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "task",
|
"name": "task",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "task",
|
"block": "task",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tasks",
|
"name": "tasks",
|
||||||
"scope": "cli",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "interact",
|
"risk": "interact",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "tool",
|
"block": "tool",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "view_image",
|
"name": "view_image",
|
||||||
"scope": "cli",
|
"scope": "cli",
|
||||||
|
"sessions": "agent",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "read",
|
"block": "read",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web_fetch",
|
"name": "web_fetch",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "web",
|
"block": "web",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web_search",
|
"name": "web_search",
|
||||||
"scope": "shared",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "read",
|
"risk": "read",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "web",
|
"block": "web",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "write",
|
"name": "write",
|
||||||
"scope": "cli",
|
"scope": "shared",
|
||||||
|
"sessions": "both",
|
||||||
"risk": "write",
|
"risk": "write",
|
||||||
"exclusive": false,
|
"exclusive": false,
|
||||||
"block": "edit",
|
"block": "edit",
|
||||||
|
|||||||
+7
-19
@@ -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
|
# 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).
|
# 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.
|
# The model to start with, by its id on the LLeMbas server you log in to (lembas models lists
|
||||||
# model: local/qwen3
|
# 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
|
# manual (asks before changes) | edit (changes files in the project freely) | plan
|
||||||
mode: manual
|
mode: manual
|
||||||
@@ -237,27 +238,14 @@ fi
|
|||||||
if [ "$starter" -eq 1 ] && [ ! -e "$CONFIG/connections.yaml" ]; then
|
if [ "$starter" -eq 1 ] && [ ! -e "$CONFIG/connections.yaml" ]; then
|
||||||
(umask 077 && cat >"$CONFIG/connections.yaml" <<'YAML'
|
(umask 077 && cat >"$CONFIG/connections.yaml" <<'YAML'
|
||||||
# yaml-language-server: $schema=schema/connections.schema.json
|
# yaml-language-server: $schema=schema/connections.schema.json
|
||||||
# Where the models are. Global only: a project can never point LLeMbas CLI elsewhere.
|
# The LLeMbas server this machine is logged in to. Global only: a project can never point
|
||||||
# Keys are never written here in plain text: {env:VAR}, {file:~/path}, or key_cmd.
|
# LLeMbas CLI elsewhere. Written by `lembas login <address>`, not by hand; the models come from
|
||||||
# Then set `model: <connection>/<model>` in config.yaml, and check with `lembas models`.
|
# the server at every start (`lembas models` lists them).
|
||||||
connections: {}
|
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
|
YAML
|
||||||
)
|
)
|
||||||
chmod 600 "$CONFIG/connections.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
|
fi
|
||||||
# The schemas for editors follow the binary, so they are rewritten on every install.
|
# 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)"
|
"$TARGET" config schema >/dev/null && say " wrote $CONFIG/schema/ (JSON Schemas for editors)"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lembas-cli",
|
"name": "lembas-cli",
|
||||||
"version": "1.5.2",
|
"version": "2.0.0-beta.2",
|
||||||
"description": "LLeMbas CLI: a terminal coding agent and project manager — OpenTUI, any LLM API, memory, skills, MCP, git-native.",
|
"description": "LLeMbas CLI: a terminal coding agent and project manager for a LLeMbas server — OpenTUI, memory, skills, MCP, git-native.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"model": {
|
"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"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"small_model": {
|
"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"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"titles": {
|
"titles": {
|
||||||
@@ -241,16 +241,13 @@
|
|||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"compaction": {
|
"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",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"auto_at": {
|
"auto_at": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"minimum": 0.1,
|
"minimum": 0.1,
|
||||||
"maximum": 1
|
"maximum": 1
|
||||||
},
|
|
||||||
"prune": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -869,7 +866,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"embedding": {
|
"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"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"effort": {
|
"effort": {
|
||||||
@@ -999,7 +996,7 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"library": {
|
"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",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"local",
|
"local",
|
||||||
|
|||||||
@@ -4,14 +4,12 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"connections": {
|
"connections": {
|
||||||
"default": {},
|
"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",
|
"type": "object",
|
||||||
"propertyNames": {
|
"propertyNames": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
@@ -24,10 +22,7 @@
|
|||||||
"description": "The instance's address, e.g. https://ai.example.org (no /v1)."
|
"description": "The instance's address, e.g. https://ai.example.org (no /v1)."
|
||||||
},
|
},
|
||||||
"api_key": {
|
"api_key": {
|
||||||
"description": "This machine's token, as login wrote it: {file:~/.config/lembas/lembas/<name>.key}. Or key_cmd.",
|
"description": "This machine's token, as login wrote it: {file:~/.config/lembas/lembas/<name>.key}.",
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"key_cmd": {
|
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"tls": {
|
"tls": {
|
||||||
@@ -167,7 +162,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"effort_style": {
|
"effort_style": {
|
||||||
"description": "openai-chat only: where the effort goes. llama.cpp drops top-level, so `both` is the default.",
|
"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",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
"top",
|
"top",
|
||||||
@@ -175,26 +170,6 @@
|
|||||||
"both"
|
"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": {
|
"vision": {
|
||||||
"description": "The model reads images: @image files, pasted paths and view_image.",
|
"description": "The model reads images: @image files, pasted paths and view_image.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@@ -203,10 +178,6 @@
|
|||||||
"description": "false: a model without tool calls — it can only talk.",
|
"description": "false: a model without tool calls — it can only talk.",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"cache": {
|
|
||||||
"description": "anthropic: prompt caching breakpoints.",
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"headers": {
|
"headers": {
|
||||||
"description": "Extra HTTP headers for this model's requests.",
|
"description": "Extra HTTP headers for this model's requests.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -230,19 +201,12 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"maxLength": 300
|
"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": {
|
"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.",
|
"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"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"group": {
|
"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).",
|
"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"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -255,313 +219,10 @@
|
|||||||
"url"
|
"url"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"dialect": {
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"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).",
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"GET",
|
|
||||||
"POST"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"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": {
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"dialect",
|
|
||||||
"base_url"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"title": "LLeMbas CLI connections.yaml",
|
"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
@@ -68,12 +68,12 @@ export async function drive(opts: { cols: number; rows: number; bin?: string; ho
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fakeHome(script: Parameters<typeof fakeProvider>[0]) {
|
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-"))
|
const home = mkdtempSync(join(tmpdir(), "lembas-drive-"))
|
||||||
mkdirSync(join(home, ".config/lembas"), { recursive: true })
|
mkdirSync(join(home, ".config/lembas"), { recursive: true })
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(home, ".config/lembas/connections.yaml"),
|
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 },
|
{ mode: 0o600 },
|
||||||
)
|
)
|
||||||
writeFileSync(join(home, ".config/lembas/config.yaml"), "model: fake/coder\nupdate: { auto: off }\n")
|
writeFileSync(join(home, ".config/lembas/config.yaml"), "model: fake/coder\nupdate: { auto: off }\n")
|
||||||
|
|||||||
+5
-5
@@ -26,11 +26,11 @@ const v = run(["--version"])
|
|||||||
check("--version", v.code === 0 && /^\d+\.\d+\.\d+/.test(v.out), v.err)
|
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)
|
// 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 })
|
mkdirSync(join(home, ".config/lembas"), { recursive: true })
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
join(home, ".config/lembas/connections.yaml"),
|
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 },
|
{ mode: 0o600 },
|
||||||
)
|
)
|
||||||
writeFileSync(join(home, ".config/lembas/config.yaml"), "model: fake/m\nupdate: { auto: off }\n")
|
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
|
// 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.
|
// 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(
|
writeFileSync(
|
||||||
join(home, ".config/lembas/connections.yaml"),
|
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 },
|
{ mode: 0o600 },
|
||||||
)
|
)
|
||||||
const p = Bun.spawn([bin, "serve", "--stdio"], { cwd: home, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" })
|
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 })
|
const d = await drive({ cols: 100, rows: 30, bin, home: h, cwd })
|
||||||
await d.waitFor("Trust this directory?")
|
await d.waitFor("Trust this directory?")
|
||||||
await d.key("\r")
|
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, "connections.yaml"), connections)
|
||||||
await Bun.write(join(cfgDir, "config.yaml"), "model: fake/coder\nupdate: { auto: off }\n")
|
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.
|
// The screen opens on the sign-in, its address focused: ctrl+r tries again from there.
|
||||||
|
|||||||
+86
-23
@@ -42,12 +42,13 @@ import type { Message } from "../provider/types.ts"
|
|||||||
import type { Hub } from "./hub.ts"
|
import type { Hub } from "./hub.ts"
|
||||||
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
|
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
|
||||||
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
|
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
|
||||||
|
import { isNote } from "../session/prefix.ts"
|
||||||
|
|
||||||
export const ACP_VERSION = 1
|
export const ACP_VERSION = 1
|
||||||
/** The `_lembas/*` extension's version (LLeMbas `api/devices.py:PROTOCOL`). 2: every
|
/** The link's version (LLeMbas `services/device_link.py:PROTOCOL`, harness/acp.md *Versions*). 3:
|
||||||
* feature beyond 1 is also named in `capabilities`, so a protocol 1 peer keeps working with
|
* 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
|
||||||
* what it knows (harness/acp.md). */
|
* each other, each saying what to update. Within 3, every feature is still named in `capabilities`. */
|
||||||
export const LEMBAS_PROTOCOL = 2
|
export const LEMBAS_PROTOCOL = 3
|
||||||
|
|
||||||
export interface Limits {
|
export interface Limits {
|
||||||
/** Directories a session may be opened in (and anything under them). Empty: none at all. */
|
/** 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
|
if (m.personalization === null) out.personalization = null
|
||||||
else if (m.personalization && typeof m.personalization === "object")
|
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) }
|
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")
|
// `library` (an instance's knowledge bases, 1.1.0) is not read: the CLI's knowledge bases are its
|
||||||
out.library = {
|
// own (spec 3.0.0), and a 3.0.0 instance sends none.
|
||||||
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 })),
|
|
||||||
}
|
|
||||||
if (m.prototypes && typeof m.prototypes === "object") out.prototypes = { offered: m.prototypes.offered === true, sources: str(m.prototypes.sources) }
|
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
|
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
|
// `_lembas/jobs` and `_lembas/job/*` methods, `jobs` in `status`, `job` on an approval, and a
|
||||||
// note's turn marked `machine`.
|
// note's turn marked `machine`.
|
||||||
"jobs",
|
"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
|
] as const
|
||||||
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
|
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). */
|
/** 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
|
/** 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,
|
* 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() {
|
private get cards() {
|
||||||
return this.lembas && this.clientProtocol >= 2
|
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. */
|
/** initialize has been answered: notifications about sessions may go out from now on. */
|
||||||
private ready = false
|
private ready = false
|
||||||
|
|
||||||
@@ -486,6 +489,8 @@ export class AcpAgent {
|
|||||||
const said = p?.clientCapabilities?._meta?.lembas
|
const said = p?.clientCapabilities?._meta?.lembas
|
||||||
this.lembas = Boolean(said)
|
this.lembas = Boolean(said)
|
||||||
this.clientProtocol = typeof said?.protocol === "number" ? said.protocol : 1
|
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
|
// 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.
|
// knows the agent — after this answer, not before it.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -576,17 +581,34 @@ export class AcpAgent {
|
|||||||
const mode = L ? clampMode(wanted ?? L.maxMode, L.maxMode) : wanted
|
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() }
|
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
|
// 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
|
// 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.
|
// one its /v1 serves; `model` a ref of this machine's, for an editor that knows them. A session
|
||||||
const model = this.refFor(p?._meta?.lembas_model) ?? (typeof p?._meta?.model === "string" ? p._meta.model : undefined)
|
// 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)
|
const effort = effortOf(p?._meta?.effort)
|
||||||
|
try {
|
||||||
session.app = createApp({ cwd, mode, asker: this.asker(session), modelTitles: false, model, ...(effort !== undefined ? { effort } : {}) })
|
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.
|
// The store's id: the same session the terminals here list and can open.
|
||||||
const id = session.app.engine.sessionId ?? crypto.randomUUID()
|
const id = session.app.engine.sessionId ?? crypto.randomUUID()
|
||||||
session.id = id
|
session.id = id
|
||||||
session.turns = turnLog(id)
|
session.turns = turnLog(id)
|
||||||
markRemote(session.app.store, 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.takeContext(session, p)
|
||||||
this.sessions.set(id, session)
|
this.sessions.set(id, session)
|
||||||
this.adopt(session)
|
this.adopt(session)
|
||||||
@@ -596,19 +618,40 @@ export class AcpAgent {
|
|||||||
return {
|
return {
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
modes: { currentModeId: session.app.engine.mode, availableModes: this.modes().map((m) => ({ id: m, name: m })) },
|
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. */
|
/** Whether a model spoken to through `connection` is this link's instance's. */
|
||||||
isInstance(connection: string | undefined): boolean {
|
isInstance(connection: string | undefined): boolean {
|
||||||
return Boolean(connection && this.instanceConnection && connection === this.instanceConnection)
|
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.
|
/** A model the instance names, as a ref on its connection here (`<login>/<served>`, which resolves
|
||||||
* `<login>/<served>` resolves to the served model under its ref now (`deepseek/x` for the
|
* to the served id, provider/refs.ts); undefined when this link has no instance. */
|
||||||
* 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. */
|
|
||||||
private refFor(served: unknown): string | undefined {
|
private refFor(served: unknown): string | undefined {
|
||||||
if (typeof served !== "string" || !served.trim() || !this.instanceConnection) return undefined
|
if (typeof served !== "string" || !served.trim() || !this.instanceConnection) return undefined
|
||||||
return `${this.instanceConnection}/${served.trim()}`
|
return `${this.instanceConnection}/${served.trim()}`
|
||||||
@@ -620,8 +663,14 @@ export class AcpAgent {
|
|||||||
const t = this.terminalFor(p)
|
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)
|
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 s = this.get(p)
|
||||||
const ref = this.refFor(p?.model)
|
const ref = p?.model !== undefined ? this.chatModel(p.model) : undefined
|
||||||
if (ref && s.app.canonicalRef(ref) !== s.app.engine.model.ref) s.app.switchModel(ref)
|
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)
|
const effort = effortOf(p?.effort)
|
||||||
if (effort !== undefined) s.app.engine.effort = effort === "off" ? null : 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" }
|
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 = createApp({ cwd: dir, ...(L ? { mode: L.maxMode } : {}), asker: this.asker(session), modelTitles: false, resuming: id })
|
||||||
session.app.resume(id)
|
session.app.resume(id)
|
||||||
markRemote(session.app.store, id)
|
markRemote(session.app.store, id)
|
||||||
|
session.app.setScope(id)
|
||||||
if (L) session.app.engine.mode = clampMode(session.app.engine.mode, L.maxMode)
|
if (L) session.app.engine.mode = clampMode(session.app.engine.mode, L.maxMode)
|
||||||
this.sessions.set(id, session)
|
this.sessions.set(id, session)
|
||||||
this.adopt(session)
|
this.adopt(session)
|
||||||
@@ -686,6 +736,7 @@ export class AcpAgent {
|
|||||||
private adopt(s: Session) {
|
private adopt(s: Session) {
|
||||||
s.off?.()
|
s.off?.()
|
||||||
s.agent = this
|
s.agent = this
|
||||||
|
this.equipShow(s)
|
||||||
s.off = s.app.bus.on((e) => {
|
s.off = s.app.bus.on((e) => {
|
||||||
if (s.agent !== this) return
|
if (s.agent !== this) return
|
||||||
this.forward(s, e)
|
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). */
|
/** `available_commands_update`: the session's command set, when it changed (or always). */
|
||||||
private sendCommands(s: Session, always = false) {
|
private sendCommands(s: Session, always = false) {
|
||||||
if (!this.ready || this.peer.isClosed || s.agent !== this) return
|
if (!this.ready || this.peer.isClosed || s.agent !== this) return
|
||||||
|
|||||||
+10
-11
@@ -15,7 +15,7 @@ import { refusedLocally } from "../project/local.ts"
|
|||||||
import type { Mode } from "../config/schema.ts"
|
import type { Mode } from "../config/schema.ts"
|
||||||
import { HARNESS_VERSION } from "../harness.ts"
|
import { HARNESS_VERSION } from "../harness.ts"
|
||||||
import { instances, keyFile, rememberProtocols, type Instance } from "../lembas/login.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 { VERSION } from "../version.ts"
|
||||||
import { AcpAgent, type Limits } from "./agent.ts"
|
import { AcpAgent, type Limits } from "./agent.ts"
|
||||||
import type { Hub } from "./hub.ts"
|
import type { Hub } from "./hub.ts"
|
||||||
@@ -150,9 +150,6 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
const caPath = instance.ca ? resolvePath(expandHome(instance.ca)) : undefined
|
const caPath = instance.ca ? resolvePath(expandHome(instance.ca)) : undefined
|
||||||
const caGone = caPath && !existsSync(caPath) ? caPath : undefined
|
const caGone = caPath && !existsSync(caPath) ? caPath : undefined
|
||||||
const loginCa = caPath && !caGone ? readFileSync(caPath, "utf8") : undefined
|
const loginCa = caPath && !caGone ? readFileSync(caPath, "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
|
|
||||||
|
|
||||||
while (!o.signal?.aborted) {
|
while (!o.signal?.aborted) {
|
||||||
attempts++
|
attempts++
|
||||||
@@ -165,6 +162,7 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
// private CA). Why discovery failed is why the dial will, said in the status.
|
// private CA). Why discovery failed is why the dial will, said in the status.
|
||||||
let ca = loginCa
|
let ca = loginCa
|
||||||
let why: string | undefined
|
let why: string | undefined
|
||||||
|
let older: string | undefined
|
||||||
try {
|
try {
|
||||||
const found = await discover(instance.base_url, loginCa ? { ca: caPath! } : undefined)
|
const found = await discover(instance.base_url, loginCa ? { ca: caPath! } : undefined)
|
||||||
protocols = spoken(found.discovery)
|
protocols = spoken(found.discovery)
|
||||||
@@ -172,9 +170,15 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
if (found.tls?.ca && found.tls.ca !== caPath) ca = readFileSync(found.tls.ca, "utf8")
|
if (found.tls?.ca && found.tls.ca !== caPath) ca = readFileSync(found.tls.ca, "utf8")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
why = (e as Error).message
|
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 unreachable = [caGone && `the CA file given at login, ${caGone}, is not there`, why ?? "could not reach the instance"].filter(Boolean).join("; ")
|
||||||
const protocol = downgraded ? 1 : linkProtocol(protocols)
|
const protocol = linkProtocol(protocols)
|
||||||
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => {
|
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => {
|
||||||
let opened = false
|
let opened = false
|
||||||
let changed = false
|
let changed = false
|
||||||
@@ -216,7 +220,7 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
// 4401/4403: the instance's word that this token is done; 1008 policy, likewise.
|
// 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" })
|
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.
|
// 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 })
|
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 })
|
resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : unreachable })
|
||||||
})
|
})
|
||||||
ws.addEventListener("error", () => {})
|
ws.addEventListener("error", () => {})
|
||||||
@@ -227,11 +231,6 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
|
|||||||
wait = first
|
wait = first
|
||||||
continue
|
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) {
|
if (outcome.refused) {
|
||||||
clearInterval(freshen)
|
clearInterval(freshen)
|
||||||
return status("stopped", outcome.final ? outcome.refused : `${outcome.refused} — sign in again: lembas login`)
|
return status("stopped", outcome.final ? outcome.refused : `${outcome.refused} — sign in again: lembas login`)
|
||||||
|
|||||||
+110
-67
@@ -17,8 +17,8 @@ import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
|
|||||||
import { hardlineRules } from "./permission/hardline.ts"
|
import { hardlineRules } from "./permission/hardline.ts"
|
||||||
import { defaultEffort } from "./provider/effort.ts"
|
import { defaultEffort } from "./provider/effort.ts"
|
||||||
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
|
||||||
import { discoverContext, unload } from "./provider/discover.ts"
|
import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
|
||||||
import { assembleSystem, instructionFiles, knowledgeScope } from "./prompt/assemble.ts"
|
import type { Changing } from "./session/prefix.ts"
|
||||||
import { describeRepo, repoState } from "./git/repo.ts"
|
import { describeRepo, repoState } from "./git/repo.ts"
|
||||||
import { findProject, persistProjectRule, trustOf, type Project } from "./project/root.ts"
|
import { findProject, persistProjectRule, trustOf, type Project } from "./project/root.ts"
|
||||||
import type { Message } from "./provider/types.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 { instances as lembasInstances, keyFile as lembasKeyFile } from "./lembas/login.ts"
|
||||||
import { PERSONAL_KEYS, personalConfig, pushPersonalization } from "./lembas/personal.ts"
|
import { PERSONAL_KEYS, personalConfig, pushPersonalization } from "./lembas/personal.ts"
|
||||||
import type { Plan } from "./plan/plan.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 type { McpServer } from "./config/schema.ts"
|
||||||
import { embedderFor } from "./library/embed.ts"
|
import { embedderFor } from "./library/embed.ts"
|
||||||
import { effortsFor } from "./provider/effort.ts"
|
import { effortsFor } from "./provider/effort.ts"
|
||||||
@@ -84,9 +84,6 @@ export interface SessionContext {
|
|||||||
instructions?: string
|
instructions?: string
|
||||||
/** The account's personalization, in place of this device's; null: the device's own. */
|
/** The account's personalization, in place of this device's; null: the device's own. */
|
||||||
personalization?: { personality: string; personality_custom: string; instructions: string } | null
|
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. */
|
/** Whether the model may write a prototype, and what a page may load. */
|
||||||
prototypes?: { offered: boolean; sources: string }
|
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. */
|
/** The subagents the task tool offers (built-in, global, and a trusted project's), as of start. */
|
||||||
agents: import("./project/agents.ts").AgentSpec[]
|
agents: import("./project/agents.ts").AgentSpec[]
|
||||||
store: Store | undefined
|
store: Store | undefined
|
||||||
/** Every configured `connection/model`, in config order. */
|
/** Every model the server serves, by its id there, in its order. */
|
||||||
modelRefs(): string[]
|
modelRefs(): string[]
|
||||||
/** A ref in any form it may be written in (the old `<login>/<model>` too) as the ref the
|
/** 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. */
|
* model has now; the ref itself when it names nothing. */
|
||||||
@@ -152,27 +149,36 @@ export interface App {
|
|||||||
context: SessionContext
|
context: SessionContext
|
||||||
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
|
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
|
||||||
setContext(ctx: SessionContext, from?: string): void
|
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. */
|
/** What the LLeMbas server's /mcp stands in for (harness spec 3.0.0, acp.md *Memory*): memory
|
||||||
const LOCAL_LIBRARY = new Set(["memory", "notes_search", "note_view", "note_manage", "knowledge_search", "knowledge_get", "skills_list", "skill_view", "skill_manage"])
|
* 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 {
|
function lembasLibraryServer(warnings: string[]): (McpServer & { source: "global" }) | undefined {
|
||||||
const all = Object.values(lembasInstances())
|
const all = Object.values(lembasInstances())
|
||||||
const keyMissing = all.length === 1 && !existsSync(lembasKeyFile(all[0]!.connection))
|
const one = all.length > 1 ? [...all].sort((a, b) => b.logged_in_at.localeCompare(a.logged_in_at))[0] : all[0]
|
||||||
if (all.length !== 1 || keyMissing) {
|
if (!one || !existsSync(lembasKeyFile(one.connection))) {
|
||||||
if (keyMissing) warnings.push("library: lembas, but the login's key file is gone (lembas login again); using this machine's library")
|
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")
|
||||||
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")
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
const one = all[0]!
|
|
||||||
return {
|
return {
|
||||||
url: `${one.base_url}/mcp`,
|
url: `${one.base_url}/mcp`,
|
||||||
// The token itself, read now: the config's {file:} substitution has already run by here.
|
// The token itself, read now: the config's {file:} substitution has already run by here.
|
||||||
headers: { authorization: `Bearer ${readFileSync(lembasKeyFile(one.connection), "utf8").trim()}` },
|
headers: { authorization: `Bearer ${readFileSync(lembasKeyFile(one.connection), "utf8").trim()}` },
|
||||||
transport: "http",
|
transport: "http",
|
||||||
oauth: false,
|
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 } } : {}),
|
...(one.ca ? { tls: { ca: one.ca } } : {}),
|
||||||
source: "global",
|
source: "global",
|
||||||
}
|
}
|
||||||
@@ -248,8 +254,9 @@ export function createApp(o: AppOptions): App {
|
|||||||
return s ? describeRepo(s) : undefined
|
return s ? describeRepo(s) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory and skills go into the system prompt as they stood when the session started: a write
|
// Memory and skills go into the system prompt as they stood when it was made: a write during the
|
||||||
// during the session reaches the file, not the prompt (Hermes' frozen snapshot).
|
// 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 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 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)
|
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)" })
|
? new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: 0 }, join(project.dir, "local"), { memory: "PROJECT MEMORY (this project only)" })
|
||||||
: undefined
|
: undefined
|
||||||
const skillList = () => loadSkills({ projectDir: projectDir(), external: loaded.config.skills?.external_dirs, disabled: loaded.config.skills?.disabled })
|
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
|
// What the system prompt holds that nothing during the session updates: taken as the session
|
||||||
// the same reason as the git block: the prompt must stay byte for byte the same, or the server
|
// starts, for the same reason as the git block. The engine makes the prompt once and resends it
|
||||||
// reads the whole conversation again.
|
// 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 = () => ({
|
const freeze = () => ({
|
||||||
memory: memoryOn ? [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n") : undefined,
|
|
||||||
skills: skillLines(skillList()),
|
skills: skillLines(skillList()),
|
||||||
git: gitSummary(),
|
git: gitSummary(),
|
||||||
tasks: projectDir() && !unsafeProjectFile(join(project.dir, "tasks.md"), project.root) ? boardSummary(project.dir) : undefined,
|
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()
|
let frozen = freeze()
|
||||||
|
|
||||||
// The library: one file for every project; an embedding model only when one is set (and usable).
|
// 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"
|
let contextFrom = "the instance"
|
||||||
|
|
||||||
// library: lembas — the account's library through the instance's /mcp, in place of this
|
// 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.
|
// machine's. Needs the one instance logged in to, with a token that has the memory scope (or
|
||||||
const lembasLibrary = loaded.config.library === "lembas" ? lembasLibraryServer(loaded.warnings) : undefined
|
// the older library).
|
||||||
|
const lembasLibrary = memoryOnServer ? lembasLibraryServer(loaded.warnings) : undefined
|
||||||
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
|
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, {
|
const mcp = new McpManager(loaded.mcp, {
|
||||||
root: project.root,
|
root: project.root,
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
@@ -318,21 +334,38 @@ export function createApp(o: AppOptions): App {
|
|||||||
sessions: store ? { store, current: () => engine.sessionId } : undefined,
|
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() },
|
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,
|
/** Whether knowledge_search is offered (engine.offered's rule): a base of this machine's with
|
||||||
* or the instance's library standing in for this machine's. */
|
* documents in scope. Knowledge bases are this machine's alone (spec 3.0.0). */
|
||||||
const knowledgeOffered = () => Boolean(lembasLibrary) || library.bases().some((b) => b.documents > 0 && (!libraryCtx.bases || libraryCtx.bases.includes(b.name)))
|
const knowledgeOffered = () => 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:`. */
|
/** The library block's scope: this machine's `knowledge:`. */
|
||||||
const libraryBlock = () => {
|
const libraryBlock = () => (knowledgeOffered() ? { scope: knowledgeScope(libraryCtx.bases) } : undefined)
|
||||||
if (!knowledgeOffered()) return undefined
|
|
||||||
const names = lembasLibrary ? context.library?.bases.map((b) => b.name) : libraryCtx.bases
|
|
||||||
return { scope: knowledgeScope(names) }
|
|
||||||
}
|
|
||||||
/** Prototypes: offered by the client for this session, or by `prototypes: true` for a terminal's. */
|
/** 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)
|
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. */
|
/** 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 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) =>
|
/** What may change during the session, as it is now (session/prefix.ts `Changing`): what the
|
||||||
assembleSystem({
|
* 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,
|
modelRef: current.ref,
|
||||||
family: familyOf(current),
|
family: familyOf(current),
|
||||||
cwd: at?.cwd ?? o.cwd,
|
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.
|
// A worktree subagent works elsewhere: its own snapshot, taken as it starts.
|
||||||
git: at ? gitSummary() : frozen.git,
|
git: at ? gitSummary() : frozen.git,
|
||||||
tasks: frozen.tasks,
|
tasks: frozen.tasks,
|
||||||
files: at ? undefined : frozen.files,
|
files: src.files,
|
||||||
subagent,
|
subagent,
|
||||||
memory: frozen.memory,
|
memory: src.memory,
|
||||||
// The skills block tells of skill_view; a subagent without the tool gets no block.
|
// The skills block tells of skill_view; a subagent without the tool gets no block.
|
||||||
skills: subagent && !subagent.skills ? undefined : frozen.skills,
|
skills: subagent && !subagent.skills ? undefined : frozen.skills,
|
||||||
manageSkills: true,
|
manageSkills: true,
|
||||||
// Read on every prompt: /personality and /settings change it without a restart.
|
// As the prompt is made; /personality and /settings later change it by a note.
|
||||||
...personalityOf(personal()),
|
personality: src.personality,
|
||||||
mcp: mcp.instructions(),
|
userInstructions: src.userInstructions,
|
||||||
|
mcp: mcpInstructions(src.mcp),
|
||||||
roster: roster(current.ref),
|
roster: roster(current.ref),
|
||||||
plan,
|
plan,
|
||||||
...(context.instructions ? { instance: { name: contextFrom, instructions: context.instructions } } : {}),
|
...(src.instance ? { instance: src.instance } : {}),
|
||||||
library: libraryBlock(),
|
library: libraryBlock(),
|
||||||
prototypes: prototypesBlock(),
|
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.
|
// The other configured models, for the task tool's `model`, or a switch.
|
||||||
const roster = (current: string) =>
|
const roster = (current: string) =>
|
||||||
loaded.refs
|
loaded.refs
|
||||||
@@ -369,14 +407,14 @@ export function createApp(o: AppOptions): App {
|
|||||||
.slice(0, 15)
|
.slice(0, 15)
|
||||||
.map((m) => `- ${m.ref}${m.spec.name ? ` (${m.spec.name})` : ""}${m.spec.notes ? ` — ${m.spec.notes}` : ""}`)
|
.map((m) => `- ${m.ref}${m.spec.name ? ` (${m.spec.name})` : ""}${m.spec.notes ? ` — ${m.spec.notes}` : ""}`)
|
||||||
.join("\n")
|
.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
|
// 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
|
// 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.
|
// 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 agentList = agents(trusted ? project.dir : undefined, loaded.warnings)
|
||||||
const offered = BUILTIN_TOOLS.filter(
|
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).
|
// 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`)
|
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 } : {}),
|
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
|
||||||
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
|
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
|
||||||
},
|
},
|
||||||
fallback: engine.o.fallback,
|
|
||||||
steerMark: MARKS.to_subagent,
|
steerMark: MARKS.to_subagent,
|
||||||
system: (mode, current, plan) =>
|
changing: () => changing(true, at),
|
||||||
|
modeText: modeNote,
|
||||||
|
system: (mode, current, plan, ch) =>
|
||||||
system(
|
system(
|
||||||
mode,
|
mode,
|
||||||
current,
|
current,
|
||||||
|
ch,
|
||||||
{
|
{
|
||||||
name: spec.name,
|
name: spec.name,
|
||||||
instructions: wt
|
instructions: wt
|
||||||
@@ -509,6 +549,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
: spec.instructions,
|
: spec.instructions,
|
||||||
report: childTools.some((t) => t.name === "report"),
|
report: childTools.some((t) => t.name === "report"),
|
||||||
skills: childTools.some((t) => t.name === "skill_view"),
|
skills: childTools.some((t) => t.name === "skill_view"),
|
||||||
|
recall: childTools.some((t) => t.name === "recall"),
|
||||||
},
|
},
|
||||||
at,
|
at,
|
||||||
plan,
|
plan,
|
||||||
@@ -562,19 +603,13 @@ export function createApp(o: AppOptions): App {
|
|||||||
compaction,
|
compaction,
|
||||||
perm,
|
perm,
|
||||||
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } },
|
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,
|
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
|
||||||
spawn,
|
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).
|
// 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()
|
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.
|
// small_model when it resolves; a broken one is said once and the session's model used instead.
|
||||||
let smallWarned = false
|
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)
|
const snapshots = o.snapshots === false ? undefined : new Snapshots(project.root, project.gitRoot)
|
||||||
engine.busyInput = loaded.config.busy_input ?? "steer"
|
engine.busyInput = loaded.config.busy_input ?? "steer"
|
||||||
const turns = new Turns(engine, snapshots, store)
|
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.
|
// By provider for an instance's models (`deepseek/deepseek-flash`), src/provider/refs.ts.
|
||||||
const modelRefs = () => loaded.refs.map((r) => r.ref)
|
const modelRefs = () => loaded.refs.map((r) => r.ref)
|
||||||
const switchModel = (ref: string) => {
|
const switchModel = (ref: string) => {
|
||||||
const m = resolveModel(loaded, ref)
|
const m = resolveModel(loaded, ref)
|
||||||
const prev = engine.model
|
|
||||||
engine.setModel(m, clientFor(m), startEffort(m))
|
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 setIn = (key: string, value: unknown) => {
|
||||||
const path = key.split(".")
|
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.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 === "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.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
|
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
|
||||||
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
|
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
|
||||||
bus.emit({ type: "setting", key, value: v })
|
bus.emit({ type: "setting", key, value: v })
|
||||||
@@ -772,9 +812,10 @@ export function createApp(o: AppOptions): App {
|
|||||||
setContext(ctx, from) {
|
setContext(ctx, from) {
|
||||||
context = ctx
|
context = ctx
|
||||||
if (from) contextFrom = from
|
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.
|
setScope(scope) {
|
||||||
engine.o.toolCtx.libraryScope = ctx.library ? engine.sessionId : undefined
|
engine.o.toolCtx.libraryScope = scope
|
||||||
|
void readServerMemory()
|
||||||
},
|
},
|
||||||
rename(title) {
|
rename(title) {
|
||||||
const id = engine.sessionId
|
const id = engine.sessionId
|
||||||
@@ -784,6 +825,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
},
|
},
|
||||||
newSession() {
|
newSession() {
|
||||||
frozen = freeze()
|
frozen = freeze()
|
||||||
|
void readServerMemory()
|
||||||
named = "no"
|
named = "no"
|
||||||
firstShown = ""
|
firstShown = ""
|
||||||
const s = store?.createSession(project.root, engine.model.ref)
|
const s = store?.createSession(project.root, engine.model.ref)
|
||||||
@@ -801,6 +843,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
if (!store) return []
|
if (!store) return []
|
||||||
const before = engine.sessionId
|
const before = engine.sessionId
|
||||||
frozen = freeze()
|
frozen = freeze()
|
||||||
|
void readServerMemory()
|
||||||
engine.resume(id, store.context(id))
|
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.
|
// A session from before titles gets its first prompt's line now, and the model's after the next reply.
|
||||||
firstShown = ""
|
firstShown = ""
|
||||||
|
|||||||
+38
-25
@@ -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 { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts"
|
||||||
import { findProject, setTrust, trustOf } from "./project/root.ts"
|
import { findProject, setTrust, trustOf } from "./project/root.ts"
|
||||||
import { effortsFor } from "./provider/effort.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 { Store } from "./session/store.ts"
|
||||||
import { promptTitle } from "./session/title.ts"
|
import { promptTitle } from "./session/title.ts"
|
||||||
import { VERSION } from "./version.ts"
|
import { VERSION } from "./version.ts"
|
||||||
@@ -23,7 +23,7 @@ Usage:
|
|||||||
lembas [-l] [-m model] [--mode M] [-c | -s id]
|
lembas [-l] [-m model] [--mode M] [-c | -s id]
|
||||||
the TUI (-c continues the last session here, -s resumes one)
|
the TUI (-c continues the last session here, -s resumes one)
|
||||||
lembas run [options] "…" one prompt, headless
|
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 list recent sessions
|
||||||
lembas sessions delete <id>… delete sessions, here and in the web UI
|
lembas sessions delete <id>… delete sessions, here and in the web UI
|
||||||
lembas sessions prune [--dry-run]
|
lembas sessions prune [--dry-run]
|
||||||
@@ -38,6 +38,9 @@ Usage:
|
|||||||
sign in to a LLeMbas instance: its models, voice and search
|
sign in to a LLeMbas instance: its models, voice and search
|
||||||
(again: read them now; --default pins its default model;
|
(again: read them now; --default pins its default model;
|
||||||
--json: one JSON object per line, for a program to read)
|
--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 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 serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
|
||||||
lembas service install | uninstall | status | logs [-f] | run
|
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
|
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
|
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,
|
… 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,
|
service, no sessions or deletes sent to it, no server library,
|
||||||
search, fetch or embeddings, no account personalization, and no
|
search, fetch or embeddings, no account personalization, and no
|
||||||
self-update. The instance still records the usage of each request,
|
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[]) {
|
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({
|
const { values, positionals } = parseArgs({
|
||||||
args,
|
args,
|
||||||
allowPositionals: true,
|
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 known = instances()
|
||||||
const json = cmd === "login" && values.json
|
const json = cmd === "login" && values.json
|
||||||
const emit = (o: object) => console.log(JSON.stringify(o))
|
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 {
|
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") {
|
if (cmd === "logout") {
|
||||||
const name = positionals[0] ?? (Object.keys(known).length === 1 ? Object.keys(known)[0]! : "")
|
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)
|
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)
|
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.`)
|
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
|
return
|
||||||
}
|
}
|
||||||
let address = positionals[0] ?? ""
|
let address = positionals[0] ?? ""
|
||||||
// `login` again with no address refreshes the one instance there is.
|
// `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)) {
|
if (again && !values.ca && isWebuiEntry(again.connection)) {
|
||||||
const r = await sync(again.connection, { setDefault: values.default })
|
const r = await sync(again.connection, { setDefault: values.default })
|
||||||
if (json) emit(doneEvent(r, false))
|
if (json) emit(doneEvent(r, false))
|
||||||
@@ -305,17 +332,11 @@ async function main() {
|
|||||||
|
|
||||||
if (cmd === "models") {
|
if (cmd === "models") {
|
||||||
const loaded = loadConfig()
|
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)) {
|
for (const [name, c] of Object.entries(loaded.connections)) {
|
||||||
console.log(
|
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"})`)
|
||||||
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})`,
|
|
||||||
)
|
|
||||||
for (const [id, spec] of Object.entries(c.models)) {
|
for (const [id, spec] of Object.entries(c.models)) {
|
||||||
// Its ref: an instance's model by provider, anything else connection/model.
|
const m = resolveModel(loaded, id)
|
||||||
const ref = loaded.refs.find((r) => r.connection === name && r.id === id)?.ref ?? `${name}/${id}`
|
|
||||||
const m = resolveModel(loaded, ref)
|
|
||||||
const bits = [
|
const bits = [
|
||||||
spec.context ? `ctx ${spec.context}` : "",
|
spec.context ? `ctx ${spec.context}` : "",
|
||||||
spec.max_output ? `out ${spec.max_output}` : "",
|
spec.max_output ? `out ${spec.max_output}` : "",
|
||||||
@@ -323,15 +344,7 @@ async function main() {
|
|||||||
spec.tools === false ? "no-tools" : "",
|
spec.tools === false ? "no-tools" : "",
|
||||||
spec.effort || spec.efforts ? `effort ${spec.effort ?? "off"} of ${effortsFor(m).join("/")}` : "",
|
spec.effort || spec.efforts ? `effort ${spec.effort ?? "off"} of ${effortsFor(m).join("/")}` : "",
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
console.log(` ${ref}${bits.length ? " — " + bits.join(", ") : ""}`)
|
console.log(` ${m.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})`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [name, why] of Object.entries(loaded.broken)) console.log(`${name} ✗ ${why}`)
|
for (const [name, why] of Object.entries(loaded.broken)) console.log(`${name} ✗ ${why}`)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function jsonSchemas(): Record<keyof typeof SCHEMA_FILES, Record<string,
|
|||||||
})
|
})
|
||||||
return {
|
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}."),
|
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."),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-31
@@ -3,8 +3,8 @@ import { join } from "node:path"
|
|||||||
import { isScalar, parse, parseDocument } from "yaml"
|
import { isScalar, parse, parseDocument } from "yaml"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { connectionFiles, isLocal, paths } from "./paths.ts"
|
import { connectionFiles, isLocal, paths } from "./paths.ts"
|
||||||
import { asMode, Config, Connection, ConnectionsFile, McpServer, PERSONALITIES, renamePermissionKeys, WebuiConnection, type PermissionConfig } from "./schema.ts"
|
import { asMode, Config, McpServer, PERSONALITIES, renamePermissionKeys, WebuiConnection, type Connection, type PermissionConfig } from "./schema.ts"
|
||||||
import { runKeyCmd, substituteDeep } from "./substitute.ts"
|
import { substituteDeep } from "./substitute.ts"
|
||||||
import { expandWebui, refreshFailures, type WebuiMark } from "../lembas/webui.ts"
|
import { expandWebui, refreshFailures, type WebuiMark } from "../lembas/webui.ts"
|
||||||
import { defaultLibrary, withInstancePersonalization } from "../lembas/personal.ts"
|
import { defaultLibrary, withInstancePersonalization } from "../lembas/personal.ts"
|
||||||
import { instances } from "../lembas/login.ts"
|
import { instances } from "../lembas/login.ts"
|
||||||
@@ -14,8 +14,8 @@ export class ConfigError extends Error {}
|
|||||||
|
|
||||||
export interface Loaded {
|
export interface Loaded {
|
||||||
config: Config
|
config: Config
|
||||||
/** A `type: webui` entry is here as the openai-chat connection it is spoken to as, with the
|
/** The LLeMbas server this machine is logged in to — at most one: its `webui` entry as the
|
||||||
* instance's models and a `webui` mark. */
|
* `/v1` it is spoken to at, with its models and a `webui` mark (custom connections are gone). */
|
||||||
connections: Record<string, Connection & { webui?: WebuiMark }>
|
connections: Record<string, Connection & { webui?: WebuiMark }>
|
||||||
/** Connections whose {env:}/{file:} substitution failed, with the reason. Unusable, not fatal. */
|
/** Connections whose {env:}/{file:} substitution failed, with the reason. Unusable, not fatal. */
|
||||||
broken: Record<string, string>
|
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())))
|
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`)
|
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
|
// 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.
|
// without a word by the schema.
|
||||||
if (typeof all.instruction_files === "string") {
|
if (typeof all.instruction_files === "string") {
|
||||||
@@ -262,41 +270,47 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
|
|||||||
|
|
||||||
const connections: Loaded["connections"] = {}
|
const connections: Loaded["connections"] = {}
|
||||||
const broken: Record<string, string> = {}
|
const broken: Record<string, string> = {}
|
||||||
// Validate each raw file, then substitute each connection on its own: a missing variable for one
|
// The login: a `webui` entry, as `lembas login` writes it. An entry of any other kind is a custom
|
||||||
// connection must not stop the others from working. In local mode, the user's own file first
|
// connection from 1.x, gone in 2.0 (the CLI is LLeMbas-server-specific): said, and read past, so
|
||||||
// (read, never written), then the local one, whose entries win by name.
|
// 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> = {}
|
const raw: Record<string, unknown> = {}
|
||||||
for (const connFile of connectionFiles()) {
|
for (const connFile of connectionFiles()) {
|
||||||
files.push(connFile)
|
files.push(connFile)
|
||||||
const mode = statSync(connFile).mode & 0o777
|
const mode = statSync(connFile).mode & 0o777
|
||||||
if (mode & 0o077) warnings.push(`${connFile} is mode ${mode.toString(8)}; chmod 600 it — it references credentials`)
|
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 {
|
try {
|
||||||
if ((c as { type?: string }).type === "webui") {
|
const w = WebuiConnection.safeParse(substituteDeep(raw[name], `connections.${name}`))
|
||||||
const w = WebuiConnection.safeParse(substituteDeep(c, `connections.${name}`))
|
|
||||||
if (w.success) {
|
if (w.success) {
|
||||||
const c = (connections[name] = expandWebui(name, w.data))
|
const c = (connections[name] = expandWebui(name, w.data))
|
||||||
if (refreshFailures[name])
|
if (refreshFailures[name])
|
||||||
warnings.push(
|
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"}`,
|
`${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("; ")}`
|
||||||
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("; ")}`
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
broken[name] = (e as Error).message
|
broken[name] = (e as Error).message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The webui connection a voice half or the search means: the one named, or the only one. A
|
/** The server a voice half or the search goes through: the login (`connection:` may still name
|
||||||
* string says why there is none. */
|
* it). A string says why there is none. */
|
||||||
const webuiFor = (named?: string): { name: string; c: Loaded["connections"][string] } | string => {
|
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}`
|
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)
|
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 }
|
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
|
// or shown there — no search or fetch through it, no embeddings, no library, no personalization
|
||||||
// (the local keys apply) — and the whole hardline stays.
|
// (the local keys apply) — and the whole hardline stays.
|
||||||
if (isLocal()) config = localOnly(config, connections, warnings)
|
if (isLocal()) config = localOnly(config, connections, warnings)
|
||||||
@@ -400,10 +414,7 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
|
|||||||
config = personal.config
|
config = personal.config
|
||||||
if (config.library === undefined && !isLocal() && defaultLibrary()) config = { ...config, library: "lembas" }
|
if (config.library === undefined && !isLocal() && defaultLibrary()) config = { ...config, library: "lembas" }
|
||||||
|
|
||||||
// An instance that speaks protocol 2 serves `<provider>/<model>` ids.
|
const refs = modelRefs(connections)
|
||||||
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))
|
|
||||||
// A config.yaml naming the model the way it was named before providers (`example/deepseek-flash`)
|
// 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>/
|
// 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.
|
// <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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The API key for a connection: `api_key` (already substituted) or the output of `key_cmd`. */
|
/** The server's token: the login's `api_key`, already substituted from its key file. */
|
||||||
export function resolveKey(name: string, c: Connection): string | undefined {
|
export function resolveKey(_name: string, c: Pick<Connection, "api_key">): string | undefined {
|
||||||
if (c.api_key) return c.api_key
|
return c.api_key || undefined
|
||||||
if (c.key_cmd) return runKeyCmd(c.key_cmd, `connections.${name}.key_cmd`)
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
+34
-35
@@ -1,6 +1,5 @@
|
|||||||
import { z } from "zod"
|
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 EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||||
export const MODES = ["manual", "edit", "auto", "plan"] as const
|
export const MODES = ["manual", "edit", "auto", "plan"] as const
|
||||||
/** The personality presets (harness/prompts/personality/, shared with the web UI), then custom. */
|
/** The personality presets (harness/prompts/personality/, shared with the web UI), then custom. */
|
||||||
@@ -54,35 +53,19 @@ export const ModelSpec = z.strictObject({
|
|||||||
top_p: z.number().min(0).max(1).optional().describe("Nucleus sampling; absent = the server's default."),
|
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."),
|
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: 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_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)."),
|
||||||
effort_map: z.partialRecord(z.enum(EFFORTS), z.number().int().positive()).optional().describe("anthropic/gemini: effort → thinking budget tokens."),
|
|
||||||
vision: z.boolean().optional().describe("The model reads images: @image files, pasted paths and view_image."),
|
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."),
|
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."),
|
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."),
|
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."),
|
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."),
|
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 type ModelSpec = z.infer<typeof ModelSpec>
|
||||||
|
|
||||||
export const Connection = z.strictObject({
|
/** Server oddities; auto is right almost always. */
|
||||||
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)."),
|
export const Quirks = z
|
||||||
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({
|
.strictObject({
|
||||||
stream_usage: Tristate.optional().describe("Ask for token usage in the stream (stream_options); auto learns when a server refuses it."),
|
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."),
|
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."),
|
||||||
@@ -90,10 +73,24 @@ export const Connection = z.strictObject({
|
|||||||
max_tokens_field: z.enum(["max_tokens", "max_completion_tokens"]).optional().describe("OpenAI reasoning models refuse `max_tokens`; most local servers only know it."),
|
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()
|
.optional()
|
||||||
.describe("Server oddities; auto is right almost always."),
|
.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."),
|
|
||||||
})
|
/** The LLeMbas server as LLeMbas CLI speaks to it: its OpenAI-shaped `/v1` with this machine's
|
||||||
export type Connection = z.infer<typeof Connection>
|
* 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,
|
/** 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
|
* 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({
|
export const WebuiConnection = z.strictObject({
|
||||||
type: z.literal("webui").describe("A LLeMbas instance: its models, their settings, its voice and its web search."),
|
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)."),
|
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."),
|
api_key: z.string().optional().describe("This machine's token, as login wrote it: {file:~/.config/lembas/lembas/<name>.key}."),
|
||||||
key_cmd: z.string().optional(),
|
|
||||||
tls: Tls.optional().describe("For an instance behind a private CA: `ca`, the PEM file that signed its certificate."),
|
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)."),
|
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."),
|
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>
|
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({
|
export const ConnectionsFile = z.strictObject({
|
||||||
connections: z
|
connections: z
|
||||||
.record(z.string(), z.union([WebuiConnection, Connection]))
|
.record(z.string(), WebuiConnection)
|
||||||
.default({})
|
.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>
|
export type ConnectionsFile = z.infer<typeof ConnectionsFile>
|
||||||
|
|
||||||
@@ -231,8 +230,8 @@ export const Update = z.strictObject({
|
|||||||
export type Update = z.infer<typeof Update>
|
export type Update = z.infer<typeof Update>
|
||||||
|
|
||||||
export const Config = z.strictObject({
|
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."),
|
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("`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."),
|
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."),
|
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)."),
|
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."),
|
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(),
|
.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(),
|
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.)"),
|
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."),
|
instruction_files: z.array(z.string()).optional().describe("More instruction files for the system prompt, besides AGENTS.md / CLAUDE.md."),
|
||||||
search: z
|
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."),
|
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."),
|
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."),
|
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."),
|
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."),
|
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."),
|
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."),
|
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
|
remote: z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
enabled: z.boolean().optional().describe("Accept work from a LLeMbas instance this machine is logged in to (lembas service). Default false."),
|
enabled: z.boolean().optional().describe("Accept work from a LLeMbas instance this machine is logged in to (lembas service). Default false."),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export type Source = "default" | "global" | "project" | "session"
|
|||||||
|
|
||||||
export interface SettingDef {
|
export interface SettingDef {
|
||||||
key: string
|
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"
|
kind: "enum" | "model" | "effort" | "number" | "boolean" | "list" | "string"
|
||||||
values?: readonly string[]
|
values?: readonly string[]
|
||||||
/** The agent may change it (through the settings tool, with approval). */
|
/** 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: "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: "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.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.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: "search.max_results", kind: "number", agent: true, scopes: ALL, fallback: "8" },
|
||||||
{ key: "memory.enabled", kind: "boolean", agent: true, scopes: ALL, reload: true, fallback: "true" },
|
{ key: "memory.enabled", kind: "boolean", agent: true, scopes: ALL, reload: true, fallback: "true" },
|
||||||
|
|||||||
+69
-3
@@ -7,6 +7,8 @@ import LOOP_JSON from "../harness/loop.json"
|
|||||||
import t_apply_patch from "../harness/tools/apply_patch.json"
|
import t_apply_patch from "../harness/tools/apply_patch.json"
|
||||||
import t_ask_user from "../harness/tools/ask_user.json"
|
import t_ask_user from "../harness/tools/ask_user.json"
|
||||||
import t_bash from "../harness/tools/bash.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_decisions from "../harness/tools/decisions.json"
|
||||||
import t_edit from "../harness/tools/edit.json"
|
import t_edit from "../harness/tools/edit.json"
|
||||||
import t_glob from "../harness/tools/glob.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_knowledge_search from "../harness/tools/knowledge_search.json"
|
||||||
import t_list from "../harness/tools/list.json"
|
import t_list from "../harness/tools/list.json"
|
||||||
import t_memory from "../harness/tools/memory.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_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_manage from "../harness/tools/note_manage.json"
|
||||||
import t_note_view from "../harness/tools/note_view.json"
|
import t_note_view from "../harness/tools/note_view.json"
|
||||||
import t_notes_search from "../harness/tools/notes_search.json"
|
import t_notes_search from "../harness/tools/notes_search.json"
|
||||||
import t_plan_submit from "../harness/tools/plan_submit.json"
|
import t_plan_submit from "../harness/tools/plan_submit.json"
|
||||||
import t_plan_update from "../harness/tools/plan_update.json"
|
import t_plan_update from "../harness/tools/plan_update.json"
|
||||||
import t_plan_write from "../harness/tools/plan_write.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_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_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_session_search from "../harness/tools/session_search.json"
|
||||||
import t_settings from "../harness/tools/settings.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_manage from "../harness/tools/skill_manage.json"
|
||||||
import t_skill_view from "../harness/tools/skill_view.json"
|
import t_skill_view from "../harness/tools/skill_view.json"
|
||||||
import t_skills_list from "../harness/tools/skills_list.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_task from "../harness/tools/task.json"
|
||||||
import t_tasks from "../harness/tools/tasks.json"
|
import t_tasks from "../harness/tools/tasks.json"
|
||||||
import t_view_image from "../harness/tools/view_image.json"
|
import t_view_image from "../harness/tools/view_image.json"
|
||||||
@@ -43,9 +56,11 @@ import { fillPrompt } from "./prompt/fill.ts"
|
|||||||
|
|
||||||
export interface ToolSpecDef {
|
export interface ToolSpecDef {
|
||||||
name: string
|
name: string
|
||||||
/** shared: both projects implement it; cli / llembas: only that one does. The execution tools
|
/** shared: both projects implement it; cli / llembas: only that one does. */
|
||||||
* (bash, edit, read…) are the CLI's: the web UI runs no agent of its own. */
|
|
||||||
scope: "shared" | "cli" | "llembas"
|
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"
|
risk: "read" | "write" | "execute" | "interact"
|
||||||
exclusive: boolean
|
exclusive: boolean
|
||||||
/** How a call is shown in a transcript: a shell command, an edit with its diff, a
|
/** 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. */
|
/** `subagent`: offered only to a subagent (report), never to a session's own model. */
|
||||||
offered?: "subagent"
|
offered?: "subagent"
|
||||||
argument_aliases?: Record<string, string>
|
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). */
|
/** `{{name}}` in the description: a fact the projects differ in, filled in by each (fillDescription). */
|
||||||
variables?: Record<string, string>
|
variables?: Record<string, string>
|
||||||
/** What a project offers of the parameters, where it offers less than all (narrowParameters). */
|
/** 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>
|
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()
|
export const HARNESS_VERSION = VERSION.trim()
|
||||||
|
|
||||||
/** How one prompt runs (harness/loop.json): the step backstop, the budgets' defaults, the wrap-up
|
/** 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 }
|
running_max: { default: number }
|
||||||
subagent_steps: { 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
|
} = LOOP_JSON
|
||||||
|
|
||||||
/** The `purpose` argument (harness/purpose.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(
|
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:
|
/** 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
|
* `...described("read")`. With `vars`, the description filled and the values kept for its
|
||||||
* parameters (toSpec). */
|
* parameters (toSpec). */
|
||||||
|
|||||||
+71
-29
@@ -9,14 +9,26 @@ import { existsSync, readFileSync } from "node:fs"
|
|||||||
import { hostname } from "node:os"
|
import { hostname } from "node:os"
|
||||||
import { VERSION } from "../version.ts"
|
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). */
|
* same number as the link's `_lembas/*` extension (acp/agent.ts LEMBAS_PROTOCOL). */
|
||||||
export const PROTOCOL = 2
|
export const PROTOCOL = 3
|
||||||
/** The ones it can log in to: protocol 2 only adds what both sides name in their capabilities, so an
|
/** The ones it can log in to and link with: 3 alone. LLeMbas CLI 2.0 needs a LLeMbas 2.0 server
|
||||||
* instance still at 1 works with what it has. */
|
* (its tree, its memory, its models only); a 1.x server speaks 1 and 2, and is refused with a word
|
||||||
export const PROTOCOLS = [1, 2]
|
* 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 {}
|
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 {
|
export interface Discovery {
|
||||||
service: "lembas"
|
service: "lembas"
|
||||||
@@ -116,10 +128,10 @@ export function spoken(d: Pick<Discovery, "protocol" | "protocols">): number[] {
|
|||||||
return PROTOCOLS.filter((p) => theirs.includes(p))
|
return PROTOCOLS.filter((p) => theirs.includes(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The `_lembas` protocol to say in the link's hello: 2 where the instance lists it, else
|
/** The `_lembas` protocol to say in the link's hello: 3, the only one this build speaks. An
|
||||||
* 1 — an instance that speaks only 1 closes a link that says 2 (4400). Features stay gated by capabilities. */
|
* instance that does not take it closes the link with 4400. Features stay gated by capabilities. */
|
||||||
export function linkProtocol(protocols: number[] | undefined): number {
|
export function linkProtocol(_protocols?: number[]): number {
|
||||||
return protocols?.includes(2) ? 2 : 1
|
return PROTOCOL
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discover(address: string, tls?: Tls): Promise<{ discovery: Discovery; tls?: Tls }> {
|
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`)
|
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 (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`)
|
if (!body.login?.device?.code) throw new LembasError(`${base} offers no device login`)
|
||||||
return { discovery: body, tls: used }
|
return { discovery: body, tls: used }
|
||||||
}
|
}
|
||||||
@@ -158,7 +170,7 @@ export interface DeviceStart {
|
|||||||
interval: number
|
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(
|
const res = await call(
|
||||||
d.login.device.code,
|
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")
|
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[]> {
|
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)
|
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")
|
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. */
|
* which of voice and web search it may use through the API. */
|
||||||
export interface InstanceInfo {
|
export interface InstanceInfo {
|
||||||
name?: string
|
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
|
connection?: string
|
||||||
version?: string
|
version?: string
|
||||||
default_model?: string | null
|
default_model?: string | null
|
||||||
@@ -270,27 +314,25 @@ export async function signOut(baseUrl: string, token: string, tls: Tls | undefin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /v1/usage: the account's credits this month, and this device's share. Every
|
/** GET /v1/usage, as the server's 2.0 answers it: counting and money, no credits. `plan`,
|
||||||
* number is null on an instance that keeps no credits or plans. */
|
* `balance`, `balance_money`, the credit `cost` fields and `device.cost_money` are null and
|
||||||
|
* `windows` empty there; they are not read. */
|
||||||
export interface Usage {
|
export interface Usage {
|
||||||
plan: { name: string; credits_per_month: number } | null
|
month: {
|
||||||
balance: number | null
|
from: string
|
||||||
/** `cost` null where the instance keeps no credits. */
|
tokens_in: number
|
||||||
month: { from: string; tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
|
tokens_out: number
|
||||||
device: { tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
|
/** What the month cost in `currency` — null when it is in more than one currency. */
|
||||||
unit: string
|
cost_money?: number | null
|
||||||
// Every one optional: an instance may have none of them.
|
/** The month's cost per currency, `{ USD: 1.25, EUR: 0.4 }`: never added together. */
|
||||||
/** The money credits are counted in: "EUR", "USD" or "GBP". */
|
cost_by_currency?: Record<string, number>
|
||||||
|
}
|
||||||
|
device: { tokens_in: number; tokens_out: number }
|
||||||
|
/** The currency `month.cost_money` is in. */
|
||||||
currency?: string
|
currency?: string
|
||||||
/** What one credit is worth, in `currency`. */
|
/** An administrator of the instance. */
|
||||||
credit_value?: number | null
|
|
||||||
/** An administrator: no limit, never refused — the spend is still counted. */
|
|
||||||
admin?: boolean
|
admin?: boolean
|
||||||
/** The balance in `currency`. `month.cost_money`, `device.cost_money` likewise. */
|
unit?: string
|
||||||
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 }[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The usage, or undefined from an instance that does not have the endpoint. */
|
/** The usage, or undefined from an instance that does not have the endpoint. */
|
||||||
|
|||||||
+39
-7
@@ -7,12 +7,13 @@
|
|||||||
// pasted into the YAML. The models are not written anywhere: a webui connection reads them from the
|
// 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
|
// 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
|
// `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 { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { join, resolve } from "node:path"
|
import { join, resolve } from "node:path"
|
||||||
import { parseDocument } from "yaml"
|
import { parseDocument } from "yaml"
|
||||||
import { expandHome, globalPaths, isLocal, paths } from "../config/paths.ts"
|
import { expandHome, 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 { 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"
|
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
|
||||||
|
|
||||||
export interface Instance {
|
export interface Instance {
|
||||||
@@ -23,8 +24,9 @@ export interface Instance {
|
|||||||
/** The instance's own name (Example). */
|
/** The instance's own name (Example). */
|
||||||
instance_name?: string
|
instance_name?: string
|
||||||
ca?: string
|
ca?: string
|
||||||
/** What the token may do, as the instance granted it: `library` in it makes the account's
|
/** What the token may do, as the instance granted it: `memory` in it (or the older `library`)
|
||||||
* library the default. An older login has none recorded until it is made again. */
|
* makes the account's memory the default. An older login has none recorded until it is made
|
||||||
|
* again. */
|
||||||
scope?: string
|
scope?: string
|
||||||
/** The link protocols the instance listed when last asked: at login, and again at every
|
/** 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. */
|
* 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
|
return typeof value === "string" && value ? value : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Each served model's ref once this login is in place (by provider, `deepseek/x`; an
|
/** Each served model's ref once this login is in place: its served id (`deepseek/x`). Read from
|
||||||
* instance that does not speak protocol 2, or a provider a connection of the user's own is named after, keeps
|
* the configuration as it now is. */
|
||||||
* `<connection>/<id>`). Read from the configuration as it now is. */
|
|
||||||
async function refsOf(connection: string): Promise<Record<string, string>> {
|
async function refsOf(connection: string): Promise<Record<string, string>> {
|
||||||
try {
|
try {
|
||||||
const { loadConfig } = await import("../config/load.ts")
|
const { loadConfig } = await import("../config/load.ts")
|
||||||
@@ -396,7 +397,29 @@ export async function login(address: string, o: LoginOptions): Promise<LoginResu
|
|||||||
const start = await startDevice(discovery, tls)
|
const start = await startDevice(discovery, tls)
|
||||||
o.onCode(start, discovery)
|
o.onCode(start, discovery)
|
||||||
const granted = await awaitToken(discovery, start, tls, { signal: o.signal, sleep: o.sleep })
|
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)
|
const connection = connectionNameFor(discovery.base_url, discovery.connection)
|
||||||
// Logging in again under the same name: the token being replaced, read before its file is.
|
// Logging in again under the same name: the token being replaced, read before its file is.
|
||||||
const before = ownInstances()[connection]
|
const before = ownInstances()[connection]
|
||||||
@@ -416,6 +439,15 @@ export async function login(address: string, o: LoginOptions): Promise<LoginResu
|
|||||||
changes.push("the previous login could not be signed out: revoke it under User → Security → Devices on the instance")
|
changes.push("the previous login could not be signed out: revoke it under User → Security → Devices on the instance")
|
||||||
}
|
}
|
||||||
await handOver(replaced)
|
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
|
// 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.
|
// signed out or replaced — it is not this project's.
|
||||||
const known = ownInstances()
|
const known = ownInstances()
|
||||||
|
|||||||
+31
-41
@@ -7,8 +7,9 @@
|
|||||||
// personality, personality_custom, instructions — from GET /v1/me/personalization when the
|
// personality, personality_custom, instructions — from GET /v1/me/personalization when the
|
||||||
// instance has it `available` (its administrator allows it); /settings writes them back there
|
// 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.
|
// 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
|
// library — with no `library:` written anywhere, a login whose token has the `memory` scope
|
||||||
// means the account's library (`lembas`); `library: local` written keeps this machine's.
|
// (or the older `library`) means the account's memory (`lembas`); `library: local` written
|
||||||
|
// keeps this machine's.
|
||||||
import { existsSync, readFileSync } from "node:fs"
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
import { isLocal } from "../config/paths.ts"
|
import { isLocal } from "../config/paths.ts"
|
||||||
import type { Config } from "../config/schema.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 {
|
export function defaultLibrary(): "lembas" | undefined {
|
||||||
const one = soleInstance()
|
const scopes = soleInstance()?.instance.scope?.split(/\s+/) ?? []
|
||||||
return one?.instance.scope?.split(/\s+/).includes("library") ? "lembas" : undefined
|
return scopes.includes("memory") || scopes.includes("library") ? "lembas" : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The instance's personalization as last read, when it is the one that counts. */
|
/** 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")
|
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
|
/** An amount in a currency: `€1.23`, `$1.23`, `£1.23` — or "" without one. */
|
||||||
* 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. */
|
|
||||||
export function money(x: number | null | undefined, currency: string | undefined): string {
|
export function money(x: number | null | undefined, currency: string | undefined): string {
|
||||||
if (x === null || x === undefined || !Number.isFinite(x) || !currency) return ""
|
if (x === null || x === undefined || !Number.isFinite(x) || !currency) return ""
|
||||||
try {
|
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. */
|
/** The month's cost, one amount per currency (`€2.00 · $0.75`) — never two currencies added
|
||||||
function moneyOf(u: Usage, given: number | null | undefined, amount: number | null | undefined): string {
|
* together — or "" when nothing was priced. */
|
||||||
if (given !== null && given !== undefined) return money(given, u.currency)
|
export function monthCost(u: Usage | undefined): string {
|
||||||
if (amount === null || amount === undefined || typeof u.credit_value !== "number") return ""
|
const by = u?.month?.cost_by_currency
|
||||||
return money(amount * u.credit_value, u.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: what the month has cost so far, per currency; "" when nothing was
|
||||||
|
* priced or the instance cannot say. */
|
||||||
/** The status bar's word: the balance, when the account has a plan, with its money when the
|
export function spendLabel(u: Usage | undefined): string {
|
||||||
* instance says it; `unlimited` for an administrator; "" otherwise. */
|
const cost = monthCost(u)
|
||||||
export function balanceLabel(u: Usage | undefined): string {
|
return cost ? `${cost} this month` : ""
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** /usage: the detail, in lines. */
|
/** /usage: the detail, in lines. */
|
||||||
export function usageLines(connection: string, u: Usage): string[] {
|
export function usageLines(connection: string, u: Usage): string[] {
|
||||||
const unit = u.unit || "credits"
|
const out = [u.admin === true ? `${connection} (administrator)` : connection]
|
||||||
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 since = u.month?.from ? u.month.from.slice(0, 10) : "the start of the month"
|
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)}`)
|
const cost = monthCost(u)
|
||||||
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)}`)
|
if (u.month) out.push(`this month (since ${since}): ${n(u.month.tokens_in)} tokens in, ${n(u.month.tokens_out)} out${cost ? ` — ${cost}` : ""}`)
|
||||||
// The rolling windows: an administrator has none that bind, so none are listed.
|
if (u.device) out.push(`this device: ${n(u.device.tokens_in)} in, ${n(u.device.tokens_out)} out`)
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
+2
-14
@@ -19,7 +19,7 @@ import { join } from "node:path"
|
|||||||
import { parse } from "yaml"
|
import { parse } from "yaml"
|
||||||
import { connectionFiles, isLocal, paths } from "../config/paths.ts"
|
import { connectionFiles, isLocal, paths } from "../config/paths.ts"
|
||||||
import type { Connection, ModelSpec, WebuiConnection } from "../config/schema.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"
|
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. */
|
/** What a loaded webui connection carries besides an openai-chat connection's fields. */
|
||||||
@@ -33,14 +33,9 @@ export interface WebuiMark {
|
|||||||
services?: InstanceInfo["services"]
|
services?: InstanceInfo["services"]
|
||||||
/** When the models were last read from the instance; absent: never. */
|
/** When the models were last read from the instance; absent: never. */
|
||||||
fetched_at?: string
|
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
|
/** 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). */
|
* `models`: `embedding: llama/nomic-embed` finds its connection by them (library/embed.ts). */
|
||||||
embeddings?: string[]
|
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 }
|
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) ?? {}) }
|
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.
|
// A model the entry names that the instance does not (yet) list: kept, as written.
|
||||||
for (const [id, spec] of unmatched) models[id] ??= spec
|
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 {
|
return {
|
||||||
dialect: "openai-chat",
|
|
||||||
base_url: `${url}/v1`,
|
base_url: `${url}/v1`,
|
||||||
...(w.api_key !== undefined ? { api_key: w.api_key } : {}),
|
...(w.api_key !== undefined ? { api_key: w.api_key } : {}),
|
||||||
...(w.key_cmd !== undefined ? { key_cmd: w.key_cmd } : {}),
|
|
||||||
...(w.tls ? { tls: w.tls } : {}),
|
...(w.tls ? { tls: w.tls } : {}),
|
||||||
...(w.timeout ? { timeout: w.timeout } : {}),
|
...(w.timeout ? { timeout: w.timeout } : {}),
|
||||||
...(w.quirks ? { quirks: w.quirks } : {}),
|
...(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,
|
models,
|
||||||
webui: {
|
webui: {
|
||||||
url,
|
url,
|
||||||
@@ -153,7 +142,6 @@ export function expandWebui(name: string, w: WebuiConnection, cache = readCache(
|
|||||||
...(cache?.instance?.name ? { name: cache.instance.name } : {}),
|
...(cache?.instance?.name ? { name: cache.instance.name } : {}),
|
||||||
...(cache?.instance?.services ? { services: cache.instance.services } : {}),
|
...(cache?.instance?.services ? { services: cache.instance.services } : {}),
|
||||||
...(cache && cache.url === url ? { fetched_at: cache.fetched_at } : {}),
|
...(cache && cache.url === url ? { fetched_at: cache.fetched_at } : {}),
|
||||||
...(Object.keys(providers).length ? { providers } : {}),
|
|
||||||
...(embeddings.length ? { embeddings } : {}),
|
...(embeddings.length ? { embeddings } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -200,7 +188,7 @@ export async function refreshWebui(o: { timeoutMs?: number; only?: string } = {}
|
|||||||
entries.map(async ([name, c]): Promise<Refreshed> => {
|
entries.map(async ([name, c]): Promise<Refreshed> => {
|
||||||
try {
|
try {
|
||||||
const w = substituteDeep(c, `connections.${name}`) as WebuiConnection
|
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)")
|
if (!token) throw new LembasError("no key (lembas login)")
|
||||||
const got = await fetchWebui(name, w.url, token.trim(), w.tls, o.timeoutMs ?? 4000)
|
const got = await fetchWebui(name, w.url, token.trim(), w.tls, o.timeoutMs ?? 4000)
|
||||||
delete refreshFailures[name]
|
delete refreshFailures[name]
|
||||||
|
|||||||
+17
-31
@@ -1,6 +1,6 @@
|
|||||||
// Embeddings for the library: `embedding: connection/model` in config.yaml, over the connection's
|
// Embeddings for the library: `embedding:` in config.yaml names an embedding model of the LLeMbas
|
||||||
// OpenAI-shaped /embeddings (llama.cpp with --embedding, vLLM, LM Studio, OpenAI…) or Ollama's
|
// server, reached at its OpenAI-shaped /v1/embeddings. Vectors are made unit length here, so a dot
|
||||||
// /api/embed. Vectors are made unit length here, so a dot product is the cosine.
|
// product is the cosine.
|
||||||
import { resolveKey, type Loaded } from "../config/load.ts"
|
import { resolveKey, type Loaded } from "../config/load.ts"
|
||||||
import { authHeaders, joinUrl, request, tlsFor } from "../provider/http.ts"
|
import { authHeaders, joinUrl, request, tlsFor } from "../provider/http.ts"
|
||||||
import { resolveModel } from "../provider/index.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 {
|
export function embedderFor(loaded: Loaded, ref: string | undefined): Embedder | undefined {
|
||||||
if (!ref) return undefined
|
if (!ref) return undefined
|
||||||
// Through the same resolver as every model ref: an instance's model by its provider
|
// An embedding model of the server, by its served id (`llama/nomic-embed`) — not a chat model, so
|
||||||
// (`llama/nomic-embed`) or in the older form (`example/nomic-embed`). The name stored with the
|
// not a model ref (lembas/webui.ts, isChatModel): found among the ids the server says it serves
|
||||||
// vectors stays the ref as written, so a library indexed under it is still found.
|
// for embeddings, or as a ref in any of its forms. The name stored with the vectors stays the ref
|
||||||
// An embedding model need not be listed on its connection (`connection/<any id>`, as before).
|
// as written, so a library indexed under it is still found.
|
||||||
let name: string
|
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
|
let id: string
|
||||||
|
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 {
|
try {
|
||||||
const resolved = resolveModel(loaded, ref)
|
id = resolveModel(loaded, ref).id
|
||||||
name = resolved.connectionName
|
|
||||||
id = resolved.id
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// An instance's embedding model by its served id (`llama/nomic-embed`): not a
|
throw new Error(`embedding: ${(e as Error).message}`)
|
||||||
// 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}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 {
|
return {
|
||||||
model: ref,
|
model: ref,
|
||||||
async embed(texts, signal) {
|
async embed(texts, signal) {
|
||||||
const res = await request(
|
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) },
|
{ 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)`,
|
`${ref} (embeddings)`,
|
||||||
)
|
)
|
||||||
const j = (await res.json()) as { data?: { embedding: number[]; index?: number }[]; embeddings?: number[][] }
|
const j = (await res.json()) as { data?: { embedding: number[]; index?: number }[] }
|
||||||
const rows = ollama ? (j.embeddings ?? []) : [...(j.data ?? [])].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)).map((d) => d.embedding)
|
return [...(j.data ?? [])].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)).map((d) => unit(d.embedding))
|
||||||
return rows.map(unit)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+28
-7
@@ -25,6 +25,7 @@ import type { ImagePart } from "../provider/types.ts"
|
|||||||
import { ToolError, truncate, type Tool, type ToolResult } from "../tool/tool.ts"
|
import { ToolError, truncate, type Tool, type ToolResult } from "../tool/tool.ts"
|
||||||
import { match } from "../permission/wildcard.ts"
|
import { match } from "../permission/wildcard.ts"
|
||||||
import { codeFrom, forget, OAuthProvider, waitForCallback, CALLBACK_PORT } from "./oauth.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 ServerConfig = McpServer & { source: "global" | "project" }
|
||||||
export type Status = "disabled" | "connecting" | "connected" | "failed" | "needs_auth"
|
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, "_")
|
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
|
/** The LLeMbas server's /mcp, for its memory (`library: lembas`, harness/acp.md *Memory*): its
|
||||||
* LLeMbas chat, and stand in for this machine's memory, notes, skills and knowledge tools. */
|
* `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"
|
export const LIBRARY_SERVER = "lembas-library"
|
||||||
/** mcp__<server>__<tool>, at most 64 characters (the providers' limit); longer ones keep a hash. */
|
/** mcp__<server>__<tool>, at most 64 characters (the providers' limit); longer ones keep a hash. */
|
||||||
export function toolName(server: string, tool: string): string {
|
export function toolName(server: string, tool: string): string {
|
||||||
@@ -361,6 +363,14 @@ export class McpManager {
|
|||||||
return s.client
|
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. */
|
/** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
|
||||||
tools(): Tool[] {
|
tools(): Tool[] {
|
||||||
const out: 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})`,
|
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,
|
schema: LOOSE,
|
||||||
jsonSchema: objectSchema(t.inputSchema),
|
jsonSchema: objectSchema(t.inputSchema),
|
||||||
// The library's writes are the account's memory and notes, not this machine: asked like
|
// The library's writes are the account's memory, not this machine: asked like the local
|
||||||
// the local memory tool (interact), never run unasked as a command would be denied.
|
// memory tool (interact), never run unasked as a command would be denied.
|
||||||
permission: () => ({ permission: name, class: readOnly ? "read" : library ? "interact" : "execute", patterns: ["*"] }),
|
permission: () => ({ permission: name, class: readOnly ? "read" : library ? "interact" : "execute", patterns: ["*"] }),
|
||||||
run: async (args, ctx) => {
|
run: async (args, ctx) => {
|
||||||
const c = await this.clientOf(s.name)
|
const c = await this.clientOf(s.name)
|
||||||
try {
|
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 scope = library ? ctx.libraryScope : undefined
|
||||||
const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown>, ...(scope ? { _meta: { lembas: { scope } } } : {}) }, undefined, {
|
const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown>, ...(scope ? { _meta: { lembas: { scope } } } : {}) }, undefined, {
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
@@ -471,8 +481,13 @@ export class McpManager {
|
|||||||
|
|
||||||
/** The system prompt's block of server instructions, or "". */
|
/** The system prompt's block of server instructions, or "". */
|
||||||
instructions(): string {
|
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 mcpInstructions(this.serverInstructions())
|
||||||
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>` : ""
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
/** 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>
|
let t: ReturnType<typeof setTimeout>
|
||||||
return Promise.race([p, new Promise<T>((_, reject) => (t = setTimeout(() => reject(new Error(message)), ms)))]).finally(() => clearTimeout(t))
|
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>` : ""
|
||||||
|
}
|
||||||
+1
-1
@@ -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.
|
# and read only while the project is trusted. Connections cannot be set here.
|
||||||
#
|
#
|
||||||
# mode: edit # manual | edit | auto | plan
|
# mode: edit # manual | edit | auto | plan
|
||||||
# model: connection/model
|
# model: provider/model
|
||||||
# permission:
|
# permission:
|
||||||
# bash:
|
# bash:
|
||||||
# "npm test *": allow
|
# "npm test *": allow
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// What it reads from outside: only the login — the user's connections.yaml, lembas.json and the key
|
// 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
|
// 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
|
// 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
|
// 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.
|
// fetch through it, no embeddings through it, no account personalization.
|
||||||
import { chmodSync, existsSync, mkdirSync } from "node:fs"
|
import { chmodSync, existsSync, mkdirSync } from "node:fs"
|
||||||
|
|||||||
+89
-34
@@ -1,6 +1,6 @@
|
|||||||
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
||||||
import { platform, release } from "node:os"
|
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 type { Mode } from "../config/schema.ts"
|
||||||
import { expandHome, paths } from "../config/paths.ts"
|
import { expandHome, paths } from "../config/paths.ts"
|
||||||
import { render, type Plan } from "../plan/plan.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 blockLibrary from "../../harness/prompts/blocks/library.md" with { type: "text" }
|
||||||
import blockPrototypes from "../../harness/prompts/blocks/prototypes.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 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 agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
|
||||||
import agentGeneral from "../../harness/prompts/agents/general.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" }
|
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 personalityFormal from "../../harness/prompts/personality/formal.md" with { type: "text" }
|
||||||
import personalitySocratic from "../../harness/prompts/personality/socratic.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).
|
// 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/.
|
// 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> = {
|
const BUILTIN: Record<string, string> = {
|
||||||
"system/default.md": defaultSystem,
|
"system/default.md": defaultSystem,
|
||||||
"system/identity.md": identity,
|
"system/identity.md": identity,
|
||||||
@@ -53,6 +55,7 @@ const BUILTIN: Record<string, string> = {
|
|||||||
"blocks/library.md": blockLibrary,
|
"blocks/library.md": blockLibrary,
|
||||||
"blocks/prototypes.md": blockPrototypes,
|
"blocks/prototypes.md": blockPrototypes,
|
||||||
"blocks/subagent.md": blockSubagent,
|
"blocks/subagent.md": blockSubagent,
|
||||||
|
"blocks/context.md": blockContext,
|
||||||
"agents/explore.md": agentExplore,
|
"agents/explore.md": agentExplore,
|
||||||
"agents/general.md": agentGeneral,
|
"agents/general.md": agentGeneral,
|
||||||
"modes/manual.md": modeManual,
|
"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.`
|
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:
|
/** 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. */
|
* a cloned repository must not be able to put ~/.ssh/… into what is sent to the model. */
|
||||||
export interface ExtraInstruction {
|
export interface ExtraInstruction {
|
||||||
@@ -141,29 +142,58 @@ export interface ExtraInstruction {
|
|||||||
global: boolean
|
global: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function instructionFiles(cwd: string, root: string, extra: ExtraInstruction[] = []): { path: string; text: string }[] {
|
/** The names a directory's instruction file may have, in order of preference: one per directory. */
|
||||||
const out: { path: string; text: string }[] = []
|
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")
|
const global = join(paths.config, "AGENTS.md")
|
||||||
if (existsSync(global)) out.push({ path: global, text: readFileSync(global, "utf8") })
|
if (existsSync(global)) out.push({ path: global, text: readFileSync(global, "utf8") })
|
||||||
let dir = cwd
|
|
||||||
let realRoot = root
|
let realRoot = root
|
||||||
try {
|
try {
|
||||||
realRoot = realpathSync(root)
|
realRoot = realpathSync(root)
|
||||||
} catch {}
|
} catch {}
|
||||||
for (;;) {
|
// Read only when it really is a file in the project: a repository could commit AGENTS.md as a
|
||||||
const hit = ["AGENTS.md", "CLAUDE.md"].map((f) => join(dir, f)).find((f) => existsSync(f))
|
// link to anything readable, and it would go to the model in every trust state.
|
||||||
if (hit) {
|
const texts = new Map<string, string>()
|
||||||
// Read only when it really is a file in the project: a repository could commit AGENTS.md as
|
const readable = (path: string) => {
|
||||||
// a link to anything readable, and it would go to the model in every trust state.
|
|
||||||
try {
|
try {
|
||||||
const real = realpathSync(hit)
|
const real = realpathSync(path)
|
||||||
if ((real === realRoot || real.startsWith(realRoot + sep)) && statSync(real).isFile()) out.push({ path: hit, text: readFileSync(real, "utf8") })
|
if ((real !== realRoot && !real.startsWith(realRoot + sep)) || !statSync(real).isFile()) return false
|
||||||
} catch {}
|
texts.set(path, readFileSync(real, "utf8"))
|
||||||
break
|
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) {
|
for (const e of extra) {
|
||||||
const path = resolve(root, expandHome(e.path))
|
const path = resolve(root, expandHome(e.path))
|
||||||
if (out.some((f) => f.path === path)) continue
|
if (out.some((f) => f.path === path)) continue
|
||||||
@@ -195,14 +225,14 @@ export interface PromptContext {
|
|||||||
/** describeRepo(): branch, changes, recent commits. */
|
/** describeRepo(): branch, changes, recent commits. */
|
||||||
git?: string
|
git?: string
|
||||||
/** The instruction files (AGENTS.md…) as read at the session's start; read now when absent. */
|
/** 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. */
|
/** Open items on the project's task board. */
|
||||||
tasks?: string
|
tasks?: string
|
||||||
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
||||||
* `report`: it has the report tool. */
|
* `report`: it has the report tool. */
|
||||||
subagent?: { name: string; instructions: string; report?: boolean }
|
subagent?: { name: string; instructions: string; report?: boolean }
|
||||||
/** The memory snapshot taken when the session started ("" when both files are empty);
|
/** The memory as it was when the prompt was made ("" when both files are empty); undefined
|
||||||
* undefined when memory is off. */
|
* when memory is off. A later change by the person goes as a note. */
|
||||||
memory?: string
|
memory?: string
|
||||||
/** The skills list (skillLines), taken when the session started. */
|
/** The skills list (skillLines), taken when the session started. */
|
||||||
skills?: string
|
skills?: string
|
||||||
@@ -216,7 +246,8 @@ export interface PromptContext {
|
|||||||
mcp?: string
|
mcp?: string
|
||||||
/** The other configured models, one line each (with their notes). */
|
/** The other configured models, one line each (with their notes). */
|
||||||
roster?: string
|
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
|
plan?: Plan
|
||||||
/** An instance's own instructions for this session (the chat's authored prompt, over the link),
|
/** 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>`. */
|
* 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
|
/** 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 is in use; `scope` the sentence naming the bases in scope, or "" for all of them. */
|
||||||
library?: { scope: string }
|
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
|
/** Prototypes are offered (blocks/prototypes.md); `sources` the sentence saying what a page
|
||||||
* may load, or "" where nothing says. */
|
* may load, or "" where nothing says. */
|
||||||
prototypes?: { sources: string }
|
prototypes?: { sources: string }
|
||||||
@@ -231,6 +264,13 @@ export interface PromptContext {
|
|||||||
|
|
||||||
const MAX_INSTRUCTION_CHARS = 40_000
|
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 {
|
export function assembleSystem(ctx: PromptContext): string {
|
||||||
const vars = {
|
const vars = {
|
||||||
agent_name: ctx.agentName ?? "LLeMbas",
|
agent_name: ctx.agentName ?? "LLeMbas",
|
||||||
@@ -265,7 +305,7 @@ export function assembleSystem(ctx: PromptContext): string {
|
|||||||
`Project root: ${ctx.root}`,
|
`Project root: ${ctx.root}`,
|
||||||
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
|
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
|
||||||
`Platform: ${platform()} ${release()}`,
|
`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}`,
|
`Model: ${ctx.modelRef}`,
|
||||||
"</env>",
|
"</env>",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
@@ -280,14 +320,16 @@ export function assembleSystem(ctx: PromptContext): string {
|
|||||||
let text = f.text.trim()
|
let text = f.text.trim()
|
||||||
if (text.length > MAX_INSTRUCTION_CHARS)
|
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]`
|
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.mcp) sections.push(ctx.mcp)
|
||||||
if (!ctx.subagent) sections.push(agentsBlock(ctx, files))
|
if (!ctx.subagent) sections.push(agentsBlock(ctx, files))
|
||||||
|
|
||||||
// Memory and skills: snapshots taken when the session started, so the prompt does not change
|
// Memory and skills: as they were when the prompt was made, so the prompt does not change under
|
||||||
// under a session (Hermes' frozen snapshot).
|
// a session (Hermes' frozen snapshot); the person's later edit to memory goes as a note.
|
||||||
if (ctx.memory !== undefined && !ctx.subagent) {
|
if (ctx.memory !== undefined && !ctx.subagent) {
|
||||||
sections.push(promptText("blocks/memory.md"))
|
sections.push(promptText("blocks/memory.md"))
|
||||||
if (ctx.memory) sections.push(ctx.memory)
|
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"))
|
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
|
// 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.
|
// 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.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 }))
|
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 : "",
|
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
else sections.push(fillPrompt(promptText(`modes/${ctx.mode}.md`), vars))
|
else sections.push(modeText(ctx.mode, ctx.planDir, ctx.unattended))
|
||||||
if (ctx.unattended) sections.push(fillPrompt(promptText("modes/unattended.md"), { ...vars, refused: ctx.unattended }))
|
|
||||||
// What the person said about themselves goes last, under the web UI's headings and in its order.
|
// 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.
|
// 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}`)
|
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")
|
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 =
|
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)."
|
"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."
|
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,
|
/** 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. */
|
* and keep it current. The project's are the chain's files (instructionFiles `chain`), stacked from
|
||||||
function agentsBlock(ctx: PromptContext, files: { path: string; text: string }[]): string {
|
* its root down: none of them is missing, and git is settled when any of them settles it. The
|
||||||
const own = files.find((f) => f.path !== join(paths.config, "AGENTS.md"))
|
* 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 }
|
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")
|
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}`
|
||||||
}
|
}
|
||||||
@@ -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, estimateValue } 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: estimateValue(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {}
|
|
||||||
}
|
|
||||||
@@ -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, estimateValue } 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: estimateValue(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
@@ -27,20 +27,10 @@ export function tlsFor(c: Pick<Connection, "tls">): { ca?: string; rejectUnautho
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_AUTH: Record<Connection["dialect"], NonNullable<Connection["auth"]>> = {
|
/** The server's token as a bearer, plus the connection's and model's own headers. */
|
||||||
"openai-chat": "bearer",
|
export function authHeaders(c: Pick<Connection, "headers">, key: string | undefined, extra: Record<string, string> = {}): Record<string, string> {
|
||||||
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> {
|
|
||||||
const h: Record<string, string> = { "content-type": "application/json", ...extra }
|
const h: Record<string, string> = { "content-type": "application/json", ...extra }
|
||||||
const style = c.auth ?? DEFAULT_AUTH[c.dialect]
|
if (key) h.authorization = `Bearer ${key}`
|
||||||
if (key && style === "bearer") h.authorization = `Bearer ${key}`
|
|
||||||
else if (key && style !== "none") h[style] = key
|
|
||||||
return { ...h, ...c.headers }
|
return { ...h, ...c.headers }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-45
@@ -1,64 +1,40 @@
|
|||||||
import { findRef, modelRefs } from "./refs.ts"
|
import { findRef, modelRefs } from "./refs.ts"
|
||||||
import type { Loaded } from "../config/load.ts"
|
import type { Loaded } from "../config/load.ts"
|
||||||
import { AnthropicClient } from "./anthropic.ts"
|
|
||||||
import { OpenAIChatClient } from "./openai-chat.ts"
|
import { OpenAIChatClient } from "./openai-chat.ts"
|
||||||
import { ResponsesClient } from "./responses.ts"
|
import type { Client, ResolvedModel } from "./types.ts"
|
||||||
import { GeminiClient } from "./gemini.ts"
|
|
||||||
import { OllamaClient } from "./ollama.ts"
|
|
||||||
import { ProviderError, type Client, type ResolvedModel } from "./types.ts"
|
|
||||||
|
|
||||||
export class ModelError extends Error {}
|
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 {
|
export function resolveModel(loaded: Loaded, ref: string | undefined): ResolvedModel {
|
||||||
const refs = loaded.refs ?? modelRefs(loaded.connections)
|
const refs = loaded.refs ?? modelRefs(loaded.connections)
|
||||||
if (!ref) {
|
const [name, connection] = Object.entries(loaded.connections)[0] ?? []
|
||||||
// A LLeMbas instance says which model its account starts on; that, before anything else.
|
if (!name || !connection) {
|
||||||
const instance = Object.entries(loaded.connections).find(([, c]) => c.webui?.default && c.models[c.webui.default])
|
const why = Object.values(loaded.broken ?? {})[0]
|
||||||
if (instance) ref = refs.find((r) => r.connection === instance[0] && r.id === instance[1].webui!.default)?.ref ?? `${instance[0]}/${instance[1].webui!.default}`
|
throw new ModelError(why ? `The LLeMbas login is unusable: ${why}` : NOT_LOGGED_IN)
|
||||||
}
|
}
|
||||||
if (!ref) {
|
if (loaded.broken?.[name]) throw new ModelError(`The LLeMbas login ${name} is unusable: ${loaded.broken[name]}`)
|
||||||
const first = refs[0]
|
if (!ref) ref = connection.webui?.default && connection.models[connection.webui.default] ? connection.webui.default : refs[0]?.ref
|
||||||
if (!first) throw new ModelError("No model configured. Add a connection to ~/.config/lembas/connections.yaml and set `model:` in config.yaml.")
|
if (!ref) throw new ModelError(`${name} serves no model to this account yet. lembas login ${connection.webui?.url ?? ""} again once it does.`.replace(" ", " "))
|
||||||
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.
|
|
||||||
const known = findRef(loaded.connections, refs, ref)
|
const known = findRef(loaded.connections, refs, ref)
|
||||||
if (known) {
|
if (known) return { ref: known.ref, connectionName: name, connection, id: known.id, spec: connection.models[known.id] ?? {} }
|
||||||
const connection = loaded.connections[known.connection]!
|
// A model the server serves that its list did not have when it was read: the server decides
|
||||||
if (loaded.broken?.[known.connection]) throw new ModelError(`Connection "${known.connection}" is unusable: ${loaded.broken?.[known.connection]}`)
|
// which models exist, so it is asked for rather than refused here. The login's own name in front
|
||||||
return { ref: known.ref, connectionName: known.connection, connection, id: known.id, spec: connection.models[known.id] ?? {} }
|
// of it (an older form) is taken off.
|
||||||
}
|
const id = ref.startsWith(`${name}/`) ? ref.slice(name.length + 1) : ref
|
||||||
const slash = ref.indexOf("/")
|
return { ref: id, connectionName: name, connection, id, spec: {} }
|
||||||
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 ?? {} }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clientFor(m: ResolvedModel): Client {
|
export function clientFor(m: ResolvedModel): Client {
|
||||||
switch (m.connection.dialect) {
|
|
||||||
case "openai-chat":
|
|
||||||
return new OpenAIChatClient(m)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
* local — every open-weight or unknown model, which gets Hermes' act-don't-describe guidance. */
|
||||||
export function familyOf(m: ResolvedModel): string {
|
export function familyOf(m: ResolvedModel): string {
|
||||||
if (m.spec.family) return m.spec.family
|
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"
|
if (/\bgpt|^o[134]\b|^o[134]-|codex/.test(id)) return "gpt"
|
||||||
return "local"
|
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
@@ -6,14 +6,12 @@ import { paths } from "../config/paths.ts"
|
|||||||
// What endpoints have taught us, kept across runs in ~/.local/state/lembas/learned.json.
|
// What endpoints have taught us, kept across runs in ~/.local/state/lembas/learned.json.
|
||||||
// Only ever narrows behaviour; deleting the file is always safe.
|
// Only ever narrows behaviour; deleting the file is always safe.
|
||||||
interface Learned {
|
interface Learned {
|
||||||
/** `connection/model` → the efforts it actually accepts. */
|
/** A model ref → the efforts it actually accepts. */
|
||||||
efforts: Record<string, Effort[]>
|
efforts: Record<string, Effort[]>
|
||||||
/** base URLs that reject `stream_options`. */
|
/** base URLs that reject `stream_options`. */
|
||||||
noStreamOptions: string[]
|
noStreamOptions: string[]
|
||||||
/** base URLs that reject `return_progress`. */
|
/** base URLs that reject `return_progress`. */
|
||||||
noProgress: string[]
|
noProgress: string[]
|
||||||
/** `connection/model` → context window found by discovery (config always wins). */
|
|
||||||
contexts: Record<string, number>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cache: Learned | undefined
|
let cache: Learned | undefined
|
||||||
@@ -25,9 +23,11 @@ function file() {
|
|||||||
export function learned(): Learned {
|
export function learned(): Learned {
|
||||||
if (cache) return cache
|
if (cache) return cache
|
||||||
try {
|
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 {
|
} catch {
|
||||||
cache = { efforts: {}, noStreamOptions: [], noProgress: [], contexts: {} }
|
cache = { efforts: {}, noStreamOptions: [], noProgress: [] }
|
||||||
}
|
}
|
||||||
return cache!
|
return cache!
|
||||||
}
|
}
|
||||||
@@ -46,11 +46,6 @@ export function learnEfforts(ref: string, efforts: Effort[]) {
|
|||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function learnContext(ref: string, n: number) {
|
|
||||||
learned().contexts[ref] = n
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function learnNoStreamOptions(baseUrl: string) {
|
export function learnNoStreamOptions(baseUrl: string) {
|
||||||
const l = learned()
|
const l = learned()
|
||||||
if (!l.noStreamOptions.includes(baseUrl)) l.noStreamOptions.push(baseUrl)
|
if (!l.noStreamOptions.includes(baseUrl)) l.noStreamOptions.push(baseUrl)
|
||||||
|
|||||||
@@ -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, estimateValue } 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: estimateValue(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -207,6 +207,10 @@ export class OpenAIChatClient implements Client {
|
|||||||
const addText = (kind: "text" | "reasoning", text: string) => out.add(kind, text)
|
const addText = (kind: "text" | "reasoning", text: string) => out.add(kind, text)
|
||||||
let finish = "stop"
|
let finish = "stop"
|
||||||
let usage: StreamEvent | undefined
|
let usage: StreamEvent | undefined
|
||||||
|
// llama.cpp's own count, in its last chunk: `timings` with `cache_n` (read from its cache) and
|
||||||
|
// `prompt_n` (read now). The cached part of a request when `usage` does not say it.
|
||||||
|
let timings: { cache: number; read: number; out?: number } | undefined
|
||||||
|
let progressCache: number | undefined
|
||||||
|
|
||||||
for await (const { data } of sseJson(stream)) {
|
for await (const { data } of sseJson(stream)) {
|
||||||
const chunk = data as any
|
const chunk = data as any
|
||||||
@@ -217,6 +221,9 @@ export class OpenAIChatClient implements Client {
|
|||||||
// string code; statusOf() still reads it as the server error it is.
|
// string code; statusOf() still reads it as the server error it is.
|
||||||
throw new ProviderError(`${m.ref}: ${String(msg).trim()}`, statusOf(err), undefined, !out.output)
|
throw new ProviderError(`${m.ref}: ${String(msg).trim()}`, statusOf(err), undefined, !out.output)
|
||||||
}
|
}
|
||||||
|
const tm = chunk?.timings
|
||||||
|
if (tm && typeof tm === "object" && Number.isFinite(tm.cache_n))
|
||||||
|
timings = { cache: Number(tm.cache_n), read: Number(tm.prompt_n) || 0, ...(Number.isFinite(tm.predicted_n) ? { out: Number(tm.predicted_n) } : {}) }
|
||||||
if (chunk?.usage && typeof chunk.usage === "object") {
|
if (chunk?.usage && typeof chunk.usage === "object") {
|
||||||
const u = chunk.usage
|
const u = chunk.usage
|
||||||
usage = {
|
usage = {
|
||||||
@@ -232,8 +239,10 @@ export class OpenAIChatClient implements Client {
|
|||||||
// llama.cpp, while it reads the prompt: { total, cache, processed, time_ms }. Anything not
|
// llama.cpp, while it reads the prompt: { total, cache, processed, time_ms }. Anything not
|
||||||
// shaped like that is ignored, never an error.
|
// shaped like that is ignored, never an error.
|
||||||
const pp = chunk?.prompt_progress
|
const pp = chunk?.prompt_progress
|
||||||
if (pp && typeof pp === "object" && Number.isFinite(pp.total) && pp.total > 0 && !out.output)
|
if (pp && typeof pp === "object" && Number.isFinite(pp.total) && pp.total > 0 && !out.output) {
|
||||||
yield { type: "progress", total: Number(pp.total), cache: Number(pp.cache) || 0, processed: Number(pp.processed) || 0, ms: Number(pp.time_ms) || 0 }
|
progressCache = Number(pp.cache) || 0
|
||||||
|
yield { type: "progress", total: Number(pp.total), cache: progressCache, processed: Number(pp.processed) || 0, ms: Number(pp.time_ms) || 0 }
|
||||||
|
}
|
||||||
const choice = chunk?.choices?.[0]
|
const choice = chunk?.choices?.[0]
|
||||||
if (!choice) continue
|
if (!choice) continue
|
||||||
const delta = choice.delta ?? choice.message ?? {}
|
const delta = choice.delta ?? choice.message ?? {}
|
||||||
@@ -272,9 +281,16 @@ export class OpenAIChatClient implements Client {
|
|||||||
if (calls.length && finish !== "length") finish = "tool_calls"
|
if (calls.length && finish !== "length") finish = "tool_calls"
|
||||||
const message = out.message()
|
const message = out.message()
|
||||||
const parts = message.parts
|
const parts = message.parts
|
||||||
|
// The cached input tokens (harness context.md §10 rule 6): the usage's own field
|
||||||
|
// (`prompt_tokens_details.cached_tokens`, or `cache_read_input_tokens` where an Anthropic-shaped
|
||||||
|
// count comes through), else llama.cpp's timings, else its last progress report.
|
||||||
|
const cached = timings?.cache ?? progressCache
|
||||||
|
if (usage?.type === "usage" && usage.usage.cached === undefined && cached !== undefined) usage.usage.cached = cached
|
||||||
|
if (!usage && timings?.out !== undefined)
|
||||||
|
usage = { type: "usage", usage: { input: timings.cache + timings.read, output: timings.out, cached: timings.cache } }
|
||||||
if (!usage) {
|
if (!usage) {
|
||||||
const out = parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.name + p.args : p.text), 0)
|
const out = parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.name + p.args : p.text), 0)
|
||||||
usage = { type: "usage", usage: { input: estimateValue(body.messages), output: out, estimated: true } }
|
usage = { type: "usage", usage: { input: estimateValue(body.messages), output: out, estimated: true, ...(cached !== undefined ? { cached } : {}) } }
|
||||||
}
|
}
|
||||||
yield usage
|
yield usage
|
||||||
yield { type: "finish", reason: finish, message }
|
yield { type: "finish", reason: finish, message }
|
||||||
|
|||||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user