38 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 6a82e6cb61 2.0.0-beta.3
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:40:15 +00:00
HomerandClaude Opus 5.5 536fd2aa45 Teams: a sub-manager's member is no looser than its manager's mode (team.md §4)
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:12:33 +00:00
HomerandClaude Opus 5.5 d3af8c2f02 Changelog: harness spec 3.2.0 in the CLI — teams, levels, reasoning, paths
ci / check (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:11:41 +00:00
HomerandClaude Opus 5.5 6ec95fbd66 Teams in the CLI (harness spec 3.2.0, team.md)
ci / check (push) Waiting to run
The four team tools (tool/team_tools.ts) on the rules of team/team.ts,
driven from real turns by team/runtime.ts:

- Members run on the subagent job machinery (app.ts spawn with `member`):
  background, unattended, their own prompt from blocks/team_member.md
  (the whole of team_lead.md for one with lead: true), team_send and
  team_read (team_add and team_stop for one that leads). Not jobs to the
  model: job_list and the job_* tools skip them; a client over the link
  still sees them as subagent jobs named "<name>: <role>".
- The room: messages and reports with their marks, delivered at the next
  step boundary or waking a finished member or the idle lead; report
  posts to the manager.
- A manager's work calls answer the refusal before the permission check,
  by risk class (an MCP tool by its readOnlyHint); the tool list is
  unchanged. job_stop and reading bash stay.
- Slots: the lead waits for one before its turn (Esc stops the wait);
  members wait in order; task helpers in a team count toward the size
  and hold a slot; job_send restarting a helper is refused when full.
- The offer is decided with the prompt and kept with it: a resumed
  session takes it from its kept tool list.
- The size: agents.min/max settings, refused outside 1 <= min <= max <=
  cap, kept across /new and resume; said in a note on the first message,
  after a change, after a compaction and a rewind.
- TUI: /team (tree and room, size, stop a member, dialogs for both), an
  "agents" row beside the effort in /effort, "team n/max" in the status
  bar. /team works over ACP too.

tests/team.test.ts runs a lead, a member and its report through the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:08:52 +00:00
HomerandClaude Opus 5.5 fbefe7f844 Harness spec 3.2.0 in the CLI: effort levels, reasoning round-trip, no absolute paths
ci / check (push) Waiting to run
Levels (models/schema.json levels): provider/effort.ts levelsOf/levelFields/
readLevel — `off` only with thinking_switch and sends enable_thinking false
and no effort, `default` for a model that takes no efforts and sends
nothing, a level not offered is read as the start level. The engine's
effort is a level; /effort shows nothing for one level, a Thinking switch
for [off, X], a list otherwise; the status bar follows. /v1/models'
thinking_switch, efforts [] and capacity.parallel are read
(lembas/webui.ts); session/capacity.ts counts parallel slots.

Reasoning (context.md §8): an assistant message of the current turn
carries reasoning_content (provider/common.ts turnReasoning,
openai-chat.ts); step-boundary deliveries, nudges and the wrap-up are
marked turn: false and do not start a turn.

Paths (context.md §11): the <env> block names the project and, started
below the root, that directory relative to it (assemble.ts envLines);
instruction files and @ attachments are named from the root or as ~/…;
file tools and bash resolve relative paths against the project root.

The team rules (team/team.ts) and conformance runners for levels,
reasoning, paths and team; the team runtime follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:36:55 +00:00
HomerandClaude Opus 5.5 5c23087cef Harness spec 3.2.0: version, README, changelog
ci / check (push) Waiting to run
VERSION 3.2.0; README's file table, the `team` requirement and a 3.2.0
section; CHANGELOG Unreleased. Two tests that pinned the AGENTS.md
block's absolute root now pin the relative text (context.md §11).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:18:21 +00:00
HomerandClaude Opus 5.5 880289970b Harness spec 3.2.0: teams — subagents that work together
team.md: a lead adds members (team_add) that work in the background on
the parts they own; members may lead areas of their own. The chat
setting agents {min, max} (defaults 1/4, an instance cap of 32) bounds
the whole tree, task helpers included; a team is offered only where
subagents are enabled and the model serves two requests at once
(capacity.parallel). A manager's work calls — risk write or execute,
bar job_stop and reading bash — answer a fixed refusal while the tool
list stays unchanged. The room: team_send to names or all, delivered
under "[Team message from X to Y:]" at the next step or waking a
finished member; a member's final reply is its report to its manager;
team_read shows the tree and the visible part of the room; team_stop
stops a subtree. Past the model's capacity a member waits for a slot,
the lead first. Size notes in context.note; texts in loop.json team;
prompts blocks/team_lead.md and team_member.md; tools allowed by
default. Conformance area team, written from a reference model.

The CLI embeds the new tool and prompt files; its tools are pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:17:48 +00:00
HomerandClaude Opus 5.5 d09d51cfec Harness spec 3.2.0: reasoning goes back during its turn; no absolute paths
context.md §8: every assistant message of the current turn (after the
last message that started a turn — a step-boundary delivery does not)
carries its reasoning back, as reasoning_content in OpenAI chat and as
thinking blocks in Anthropic; earlier turns carry none, which the common
templates render identically. §11: an agent session's prompt names no
absolute machine path: the environment block names the project and says
paths are relative to its root (loop.json context.env), files outside
the project under home as ~/…; relative paths resolve against the
project root, absolute ones still accepted. agents-missing.md and
init.md no longer name the root. New conformance areas: reasoning, paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:08:53 +00:00
HomerandClaude Opus 5.5 fdfcf3666a Harness spec 3.2.0: effort levels, the thinking switch, parallel capacity
models/schema.json: thinking_switch (the server turns thinking off for
chat_template_kwargs.enable_thinking: false) and capacity.parallel
(single_session is parallel 1). The level `off` exists only with the
switch and sends enable_thinking false and no effort; a model that takes
no efforts has the one level `default`, which sends nothing. The levels a
chat offers are (off) + efforts; one level shows no control, [off, X] a
Thinking switch, more a slider. A start level and a level the model does
not offer follow one rule. quirks.json: thinking-default; acp.md: effort
values are levels. New conformance area: levels.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:48:50 +00:00
214 changed files with 13370 additions and 3255 deletions

No files matched your search

+149 -1
View File
@@ -4,7 +4,155 @@ 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.3] — 2026-11-09
Harness spec 3.2.0: teams, effort levels that mean what they say, the model's reasoning sent
back while it works, and no absolute paths in the prompt.
### Added
- **Teams.** Where the model serves at least two requests at once, the agent can add **members**
(`team_add`): background agents that each own a part of the work, briefed on their own and told
the team's goal. Once it has a member the agent **leads**: tools that change things answer that
it should give the work to a member, while reading, reviewing and managing stay. Members and the
lead talk in the team's **room** (`team_send`, to a name or to `all`); a member's final reply
comes back as its report and wakes the lead if it was idle, and a message wakes a member that
had finished. A member made with `lead: true` leads an area with members of its own.
`team_read` shows the team and the room, `team_stop` stops a member and everything under it.
- **How many agents a team may have**: the `agents` setting (`min` 1 and `max` 4 by default, at
most `agents.cap`, 32), changed with `/team size <min> <max>`, from the **agents** row beside the
effort in `/effort`, or in `/settings`; the agent is told the size and every change in a note.
`max: 1` means one agent and no helpers; `min: 2` or more makes the agent lead from the start.
`subagents: false` switches teams off together with `task`.
- **`/team`** shows the team as a tree and the room, and stops a member (`/team stop <name>`); the
agent is told who you stopped. The status bar shows `team 3/4 · 2 working` while there is a team.
- When every slot of the model is busy, a member (or the agent itself) **waits for a free slot**
and starts when one frees, instead of being refused; one-off `task` helpers count toward the
team's size.
- **A Thinking switch.** A model that can switch thinking off offers `off` as a real level: no
thinking at all, not the model's default. `/effort` shows a list for several levels, an on/off
switch where that is the only choice, and nothing where there is nothing to choose.
### Changed
- **The model's reasoning goes back to it during its turn**, so it carries on from what it thought
instead of working it out again at every step; earlier turns are sent without it, as before.
- **No absolute paths in the prompt.** The agent is told the project's name and, when you started
below its root, that directory relative to it. Relative paths in every file tool and the
directory `bash` starts in are taken from the project root; absolute paths still work.
Instruction files are named from the project root or as `~/…`.
## [2.0.0-beta.2] — 2026-11-07
Harness spec 3.1.0: what the model has already read stays exactly as it was, so its server
can reuse it instead of reading the whole session again.
### Changed
- **The system prompt is made once per session** — at the start, after a compaction, on a model
switch — kept with the session and sent byte for byte on every request; a resumed session sends
the one it kept. It holds no date any more.
- **What changes during a session is said in a note** after your words on your next message: the
date (on the first message, and when it changes), a permission mode you chose, a plan you
changed, memory or an instruction file you edited, a personality you picked, an MCP server's
instructions that arrived later. The model's own changes (its plan, its edits, a plan approval)
are not said again. Notes are not shown as your words.
- **The tool list is the same for the whole session.** `plan_submit` is offered in every mode and
answers that it works only in plan mode; `show` is offered from the start where the session can
have a viewer; knowledge search is decided as the session starts. The tools stay listed when a
step limit or budget winds a prompt up (a call in that answer is not run), and a tool refused
for good stays listed and answers that refusal. The first request waits up to 3 seconds for the
configured MCP servers, so their tools are in the first list.
- **Compaction asks for its summary as the next turn of the same conversation** instead of a
separate request with a pasted transcript, so it reuses what the server has read and no longer
loses the middle of a long session. A tool call in the summary's reply is not run.
### Added
- **Cached input tokens are recorded for every request** (the usage's own count, or llama.cpp's
`timings` and progress), kept with the session, and shown by `/context` for the last request.
## [2.0.0-beta.1] — 2026-11-03
LLeMbas CLI 2.0 works with LLeMbas 2.0 and nothing else: it uses the server it is logged in to,
keeps tool results short without ever rewriting what the model has already read, and reads every
instruction file on the way down to where it was started. Harness spec 3.0.0.
### Changed
- **The CLI talks only to the LLeMbas server it is logged in to.** Custom connections are gone:
the OpenAI-compatible, Responses, Anthropic, Gemini and Ollama connections in `connections.yaml`,
with their `dialect`, `key_cmd`, `auth`, `discover`, `unload_url` and `one_model_at_a_time`,
and a model's `fallback`. Log in with `lembas login <address>`; without a login the CLI says so
and how. An old `connections.yaml` still starts: an entry that is not the login is named in a
warning and ignored. One server at a time: logging in to another signs this machine out of the
first.
- **Models are named as the server serves them** (`deepseek/deepseek-flash`), with no connection
in front; a `config.yaml` naming one the old way is rewritten once. The context window,
efforts and capacity are the server's word.
- **Memory is the server's**, shared with your chats there (when the login's token may reach it):
the `memory` tool and the memory in the prompt. Notes, knowledge bases and skills stay on this
machine.
- **Logging in asks for the `memory` scope** in place of `library`; a login made before keeps
working with the one it has.
- **Usage is money, not credits.** The status bar shows what the month has cost so far, and
`/usage` the month's tokens and cost and this device's tokens — each currency's amount on its
own (`€2.00 · $0.75`), never added together. Plans, balances, rolling windows and the
`unlimited` of an administrator are gone with the server's credits.
- **Instruction files stack.** Every `AGENTS.md` (or `CLAUDE.md`) from the project root down to the
working directory goes to the model, outermost first — not only the nearest one.
- **Tool results are kept short when they come in, and never changed afterwards**, so the server's
prompt cache holds. Every result has an id (`[#0001]`); a read says its lines and a hash of the
file; reading an unchanged file again, or the same listing or search with the same result,
answers with a pointer to the earlier result; output over 400 lines or 40,000 characters keeps its
beginning and its end; edits answer with their diff. `compaction.prune`, which cut old outputs
and made the server read the whole conversation again, is removed.
- **The link speaks protocol 3** with a LLeMbas 2.0 server. A 1.x server is refused at login and by
the link, saying to update the server (or use LLeMbas CLI 1.x); a newer one says to update the CLI.
- **A chat started on a model runs on that model.** When the web UI opens a session on this device
with a model the device cannot run, it is refused with the reason instead of being answered on
the device's own model.
- **File tools speak the server's words.** A missing file is `No such file or directory: <path>`
from `read`, `edit`, `multiedit`, `show`, `list`, `glob` and `grep` alike; a file `write` or
`edit` may not change yet says it has not been read, or changed since it was last read, with its
full path. `glob` and `grep` give full paths. `list` sorts names exactly (capitals first) and says
when it stopped at 400 lines.
- **A new skill's name** is lowercase letters, digits and `-`, up to 64 characters, as the Agent
Skills format has it; `_` and `.` are no longer accepted when `skill_manage` makes one. Skills
that already have such a name still load.
### Added
- **`recall`**: the model can see any earlier tool result again, in full or from a line on — the
part of a long output left out, or a result from before the conversation was summarised. After
a summary the model also gets a table of the files it had read.
- **`show`**, in a session the web UI or LLeMbas Desktop opened: the model can open a file it made
— a page, a document — in your viewer beside the chat.
- **`lembas login --token-stdin --server <url>`**: sign in with a token a program already holds —
LLeMbas Desktop, from the one approval that gives it the agent's token beside its own. The token
is read from stdin only, never from an argument or the environment, and checked against the
server first: it must speak protocol 3, accept the token, and say it carries `link`. The login
is then stored exactly as a sign-in by code (an earlier one replaced and its token signed out),
and `--json` prints the same `done` (or `error`) line.
## [1.5.2] — 2026-10-25
Logging in again leaves one device, and a link that cannot connect says why.
### Fixed
- **Logging in again left an offline twin of this device on the instance.** A second
`lembas login` to the same instance overwrote the old token without signing it out, so the web
UI showed the device twice -- the new one linked, the old one offline and holding its agent
chats. The old token is now signed out, naming the new one, and a LLeMbas server 1.7.2 or later
moves the old device's agent chats to it. A sign-out that fails is said, with where to revoke it.
- **`lembas service` could not link when the login was given its CA as a relative path.** `login
--ca` kept the path as typed, and the service, started in another directory, did not find it and
dialled with no CA at all. The path is now kept in full, and a link to an instance whose
certificate is signed by a CA in the system's store gets through even without `--ca`, as the
rest of the CLI already did.
- **"Could not reach the instance" now says why**: a CA file given at login that is gone, a
certificate that is not trusted, or what else the connection met.
## [1.5.1] — 2026-10-23 ## [1.5.1] — 2026-10-23
+13 -10
View File
@@ -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.
+134 -8
View File
@@ -7,7 +7,7 @@ metadata. This directory writes that design down as data. Each project implement
language; neither ever runs the other. LLeMbas CLI embeds these files at build time; LLeMbas vendors a pinned, hash-checked copy. 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,17 @@ 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 and the thinking switch, vision, tools, notes, capacity (`parallel`); `levels` — the effort levels a chat offers and what each sends |
| `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; `team` — a team's size, names, marks, refusals and texts; `context` — result ids, stubs, the output cap, the compaction table, the environment lines, the session notes |
| `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; the prefix that stays still; reasoning sent back during its turn; no absolute paths in the prompt |
| `team.md` | Teams: a lead and the members it adds, the size setting, the room, who receives what, reports, a manager's refusals, capacity, stopping |
| `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 +38,138 @@ 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; `team` (3.2.0): the session's team setting allows more than one agent, on a model that
serves two requests at once (`team.md` §2). 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.2.0
Written ahead of both implementations; both carry it in the same round.
- **Effort levels and the thinking switch** (`models/schema.json`). `thinking_switch`: the model's
server turns thinking off for `chat_template_kwargs.enable_thinking: false`. The level `off`
exists only then, and sends that and no effort — before, `off` sent nothing, which left the model
at its server's default effort. A model that takes no efforts has the one level `default`, which
sends nothing. The levels a chat offers are `off` (with the switch) and the efforts; one level
shows no control, `[off, X]` a Thinking on/off switch, more a slider. `capacity.parallel`: how
many requests the model serves at once (`single_session` is `parallel: 1`). Conformance area
`levels`; `quirks.json` `thinking-default`; in `acp.md` an `effort` is one of these levels.
- **Reasoning goes back during its turn** (`context.md` §8): every assistant message after the last
message that started a turn carries its reasoning (`reasoning_content`; Anthropic thinking
blocks); earlier turns carry none. Area `reasoning`.
- **No absolute paths in the prompt** (`context.md` §11): an agent session's environment block names
the project and says paths are relative to its root (`loop.json` `context.env`); relative paths
resolve against the project root, absolute ones are still taken. Area `paths`.
- **Teams** (`team.md`): `team_add`, `team_send`, `team_read`, `team_stop` (shared, both,
`requires: team`); the chat setting `agents {min, max}`; the room and its marks; reports to the
manager; the size across the tree, `task` helpers included; a manager's work tools refused with a
fixed text while the tool list stays the same; members waiting for a slot past the model's
capacity; `blocks/team_lead.md` and `blocks/team_member.md`; size notes in `context.note`. Area
`team`.
## 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 +250,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
View File
@@ -1 +1 @@
2.0.0 3.2.0
+104 -57
View File
@@ -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 *&lt;server&gt;
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` one of the model's levels (`models/schema.json` `levels`, spec 3.2.0: `"off"` where the model has `thinking_switch`, `"default"` where it takes no efforts, else an effort); a level the model does not offer is read as its start level, and the result says the level used.
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.
@@ -269,7 +284,7 @@ client hears the new limits.
| `task` | `state: "start" \| "end"`, `turnId` — every turn has one: the client's, or one made here for a turn typed in a terminal | | `task` | `state: "start" \| "end"`, `turnId` — every turn has one: the client's, or one made here for a turn typed in a terminal |
| `steered` | `texts`, `ids?` — the `_lembas/steer` message ids taken in at this step | | `steered` | `texts`, `ids?` — the `_lembas/steer` message ids taken in at this step |
| `mode` | `mode` | | `mode` | `mode` |
| `model` | `ref` (the device's ref, above), `effort` (a level or `"off"`), `connection` (the device connection it is spoken to through) and **`instance: boolean`** — true exactly when that connection is the login of **this link's** instance, i.e. the model is one the instance serves (its served id is `ref`, or `ref` less a leading `<connection>/`). Whenever either changes, for any reason: `/model`, `/effort`, the settings tool, a fallback, `_lembas/configure` | | `model` | `ref` (the device's ref, above), `effort` (one of the model's levels: an effort, `"off"` or `"default"`), `connection` (the device connection it is spoken to through) and **`instance: boolean`** — true exactly when that connection is the login of **this link's** instance, i.e. the model is one the instance serves (its served id is `ref`, or `ref` less a leading `<connection>/`). Whenever either changes, for any reason: `/model`, `/effort`, the settings tool, a fallback, `_lembas/configure` |
| `plan` | `plan` — the structure, or `null` when cleared (what the `plan` session update carries) | | `plan` | `plan` — the structure, or `null` when cleared (what the `plan` session update carries) |
| `compacted` | `summary` | | `compacted` | `summary` |
| `title` | `id`, `title` | | `title` | `id`, `title` |
@@ -286,7 +301,7 @@ Only on the link (an error on `serve --stdio`).
### `_lembas/configure` — client → agent → result ### `_lembas/configure` — client → agent → result
Params `{ sessionId, model?: <instance's name>, effort?: <level> | "off" }` between prompts. Result Params `{ sessionId, model?: <instance's name>, effort?: <level> }` between prompts (a level as in `session/new`). Result
`{ model: <ref>, effort }`. Emits a `model` event when something changed. `{ model: <ref>, effort }`. Emits a `model` event when something changed.
### `_lembas/steer` — client → agent → result ### `_lembas/steer` — client → agent → result
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{ {
"about": "Text → the pieces a knowledge document is cut into for search: about `size` characters (raised to 200 when smaller), cut just after a paragraph break, else a line break, else a sentence's \". \", found in the window's last third, with `overlap` characters (at most half the size) repeated; CRLF read as LF; pieces under 40 characters dropped.", "about": "Text → the pieces a knowledge document is cut into for search: about `size` characters (raised to 200 when smaller), cut just after a paragraph break, else a line break, else a sentence's \". \", found in the window's last third, with `overlap` characters (at most half the size) repeated; CRLF read as LF; pieces under 40 characters dropped. Spec 3.0.0: indexing for search cuts by tokens instead (chunk_tokens.json, filesystem.md); this character splitter stays the rule wherever a piece is still measured in characters.",
"cases": [ "cases": [
{ {
"size": 200, "size": 200,
+77
View File
@@ -0,0 +1,77 @@
{
"about": "Text → the pieces a file is cut into for search, measured in tokens (filesystem.md, Indexing, by tokens): at most `size` tokens each (raised to 16 when smaller), the next one beginning at the start of the token `overlap` tokens (at most half the size) before the cut; the cut just after a paragraph break, else a line break, else a sentence's \". \", looked for in the window's last third (by characters), else at the end of the window's last token; CRLF read as LF, the text trimmed, every piece trimmed; pieces under 40 characters dropped; a text of at most `size` tokens is one piece. `tokenizer: words` is the stand-in for an embedder's: a token is a run of non-whitespace characters. LLeMbas: services/library/chunks.py (to be written); CLI: library/chunks.ts (to be written).",
"cases": [
{
"tokenizer": "words",
"size": 480,
"overlap": 64,
"text": "One short paragraph that fits in a single piece.",
"expect": [
"One short paragraph that fits in a single piece."
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 4,
"text": "The first paragraph is here, with nine words in it.\n\nThe second paragraph follows it and has ten words in total.\n\nA third one closes the text with a few more words.",
"expect": [
"The first paragraph is here, with nine words in it.\n\nThe second paragraph follows it and",
"paragraph follows it and has ten words in total.\n\nA third one closes the text with"
]
},
{
"tokenizer": "words",
"size": 20,
"overlap": 5,
"text": "Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega. Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega.",
"expect": [
"Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon",
"pi rho sigma tau upsilon phi chi psi omega. Alpha beta gamma delta epsilon zeta eta theta iota kappa lambda",
"eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega."
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 4,
"text": "line one has four\nline two has four\nline three has five words\nline four has five words\nline five is the last one here\n",
"expect": [
"line one has four\nline two has four\nline three has five words",
"three has five words\nline four has five words\nline five is the last one here"
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 40,
"text": "w0 w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12 w13 w14 w15 w16 w17 w18 w19 w20 w21 w22 w23 w24 w25 w26 w27 w28 w29 w30 w31 w32 w33 w34 w35 w36 w37 w38 w39 w40 w41 w42 w43 w44 w45 w46 w47 w48 w49 w50 w51 w52 w53 w54 w55 w56 w57 w58 w59",
"expect": [
"w0 w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12 w13 w14 w15",
"w8 w9 w10 w11 w12 w13 w14 w15 w16 w17 w18 w19 w20 w21 w22 w23",
"w16 w17 w18 w19 w20 w21 w22 w23 w24 w25 w26 w27 w28 w29 w30 w31",
"w24 w25 w26 w27 w28 w29 w30 w31 w32 w33 w34 w35 w36 w37 w38 w39",
"w32 w33 w34 w35 w36 w37 w38 w39 w40 w41 w42 w43 w44 w45 w46 w47",
"w40 w41 w42 w43 w44 w45 w46 w47 w48 w49 w50 w51 w52 w53 w54 w55",
"w48 w49 w50 w51 w52 w53 w54 w55 w56 w57 w58 w59"
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 4,
"text": "a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd ",
"expect": [
"a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d",
"a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd a3f29c0e1b7d 9d8e7f6a5b4c 0011aabbccdd"
]
},
{
"tokenizer": "words",
"size": 16,
"overlap": 4,
"text": " \r\n ",
"expect": []
}
]
}
+480
View File
@@ -0,0 +1,480 @@
{
"about": "Context hygiene (context.md, loop.json `context`): what is inserted into the conversation for each step of a session, in order. Steps: `read` (`path`, the file's whole `content`, `offset`, `limit` — the read tool's body: `<n>: ` lines, the note when lines remain), `edit` / `write` (`path`, `before` — absent for a new file — and `after`), `output` (any other tool's result: `tool`, `args`, `text`), `recall` (`target`, `offset`, `limit`), `reply` (a new reply begins whose request does not carry the results inserted so far — a chat's next reply: they stay in the history a compaction covers, but no stub may name them) and `compact` (the table added after a summary, from the full reads since the previous compaction in the history; the request and the dedupe table are emptied). Each step but `reply` and `compact` carries the `id` the session gives its result; `reply` answers \"\", and so does a `compact` with no reads. `limits` overrides loop.json's `cap` for the case. The answer is the text each step inserts: a read headed by path, lines, length and the first 8 hex digits of the content's SHA-256, a stub for an unchanged re-read inside an earlier full read, a diff for an edit, a capped output with its elided line, a stub for a repeated listing or search with the same result. LLeMbas: services/context.py; CLI: session/results.ts.",
"cases": [
{
"steps": [
{
"op": "read",
"id": "#a001",
"path": "src/a.ts",
"content": "const a = 1\nexport { a }\n"
},
{
"op": "read",
"id": "#a002",
"path": "src/a.ts",
"content": "const a = 1\nexport { a }\n"
}
],
"expect": [
"[#a001 src/a.ts lines 1-2 of 2 sha 583e86b4]\n1: const a = 1\n2: export { a }",
"[#a002 src/a.ts lines 1-2 of 2 sha 583e86b4] Unchanged since #a001, which already shows lines 1-2."
]
},
{
"steps": [
{
"op": "read",
"id": "#b001",
"path": "notes.md",
"content": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n",
"limit": 5
},
{
"op": "read",
"id": "#b002",
"path": "notes.md",
"content": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n",
"offset": 2,
"limit": 3
},
{
"op": "read",
"id": "#b003",
"path": "notes.md",
"content": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n",
"offset": 4,
"limit": 5
},
{
"op": "read",
"id": "#b004",
"path": "notes.md",
"content": "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n",
"offset": 5,
"limit": 2
}
],
"expect": [
"[#b001 notes.md lines 1-5 of 10 sha e71d970d]\n1: line 1\n2: line 2\n3: line 3\n4: line 4\n5: line 5\n\n(5 more lines — read again with offset 6)",
"[#b002 notes.md lines 2-4 of 10 sha e71d970d] Unchanged since #b001, which already shows lines 1-5.",
"[#b003 notes.md lines 4-8 of 10 sha e71d970d]\n4: line 4\n5: line 5\n6: line 6\n7: line 7\n8: line 8\n\n(2 more lines — read again with offset 9)",
"[#b004 notes.md lines 5-6 of 10 sha e71d970d] Unchanged since #b003, which already shows lines 4-8."
]
},
{
"steps": [
{
"op": "read",
"id": "#c001",
"path": "a.py",
"content": "x = 1\ny = 2\nz = 3\n"
},
{
"op": "edit",
"id": "#c002",
"path": "a.py",
"before": "x = 1\ny = 2\nz = 3\n",
"after": "x = 1\ny = 20\nz = 3\n"
},
{
"op": "read",
"id": "#c003",
"path": "a.py",
"content": "x = 1\ny = 20\nz = 3\n"
},
{
"op": "read",
"id": "#c004",
"path": "a.py",
"content": "x = 1\ny = 20\nz = 3\n"
}
],
"expect": [
"[#c001 a.py lines 1-3 of 3 sha af198c55]\n1: x = 1\n2: y = 2\n3: z = 3",
"[#c002 a.py sha a6a8e52b]\n@@ -1,3 +1,3 @@\n x = 1\n-y = 2\n+y = 20\n z = 3",
"[#c003 a.py lines 1-3 of 3 sha a6a8e52b]\n1: x = 1\n2: y = 20\n3: z = 3",
"[#c004 a.py lines 1-3 of 3 sha a6a8e52b] Unchanged since #c003, which already shows lines 1-3."
]
},
{
"steps": [
{
"op": "read",
"id": "#d001",
"path": "a.txt",
"content": "one\n"
},
{
"op": "read",
"id": "#d002",
"path": "a.txt",
"content": "one\ntwo\n"
},
{
"op": "read",
"id": "#d003",
"path": "b.txt",
"content": "one\n"
}
],
"expect": [
"[#d001 a.txt lines 1-1 of 1 sha 2c8b08da]\n1: one",
"[#d002 a.txt lines 1-2 of 2 sha c3f9c8c2]\n1: one\n2: two",
"[#d003 b.txt lines 1-1 of 1 sha 2c8b08da]\n1: one"
]
},
{
"limits": {
"max_lines": 6,
"max_chars": 40000,
"head_lines": 2,
"head_chars": 20000,
"tail_lines": 2,
"tail_chars": 10000
},
"steps": [
{
"op": "output",
"id": "#e001",
"tool": "bash",
"args": {
"command": "seq 10"
},
"text": "1\n2\n3\n4\n5\n6\n7\n8\n9\n10"
},
{
"op": "recall",
"id": "#e002",
"target": "#e001",
"offset": 3,
"limit": 2
},
{
"op": "recall",
"id": "#e003",
"target": "e001"
}
],
"expect": [
"[#e001]\n1\n2\n[6 lines elided, full output #e001]\n9\n10",
"[#e002 recall #e001 lines 3-4 of 10]\n3\n4",
"[#e003 recall #e001 lines 1-10 of 10]\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10"
]
},
{
"steps": [
{
"op": "output",
"id": "#f001",
"tool": "list",
"args": {
"path": "src"
},
"text": "src/\n a.ts\n b.ts"
},
{
"op": "output",
"id": "#f002",
"tool": "list",
"args": {
"path": "src"
},
"text": "src/\n a.ts\n b.ts"
},
{
"op": "output",
"id": "#f003",
"tool": "list",
"args": {
"path": "src",
"depth": 1
},
"text": "src/\n a.ts\n b.ts"
},
{
"op": "output",
"id": "#f004",
"tool": "list",
"args": {
"path": "src"
},
"text": "src/\n a.ts\n b.ts\n c.ts"
},
{
"op": "output",
"id": "#f005",
"tool": "list",
"args": {
"path": "src"
},
"text": "src/\n a.ts\n b.ts\n c.ts"
}
],
"expect": [
"[#f001]\nsrc/\n a.ts\n b.ts",
"[#f002] Same result as #f001.",
"[#f003]\nsrc/\n a.ts\n b.ts",
"[#f004]\nsrc/\n a.ts\n b.ts\n c.ts",
"[#f005] Same result as #f004."
]
},
{
"steps": [
{
"op": "output",
"id": "#1001",
"tool": "bash",
"args": {
"command": "git status"
},
"text": "nothing to commit"
},
{
"op": "output",
"id": "#1002",
"tool": "bash",
"args": {
"command": "git status"
},
"text": "nothing to commit"
},
{
"op": "output",
"id": "#1003",
"tool": "grep",
"args": {
"pattern": "TODO"
},
"text": "a.ts:3: // TODO"
},
{
"op": "output",
"id": "#1004",
"tool": "grep",
"args": {
"pattern": "TODO"
},
"text": "a.ts:3: // TODO"
}
],
"expect": [
"[#1001]\nnothing to commit",
"[#1002]\nnothing to commit",
"[#1003]\na.ts:3: // TODO",
"[#1004] Same result as #1003."
]
},
{
"steps": [
{
"op": "read",
"id": "#2001",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "read",
"id": "#2002",
"path": "b.md",
"content": "beta\n"
},
{
"op": "read",
"id": "#2003",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "output",
"id": "#2004",
"tool": "glob",
"args": {
"pattern": "*.md"
},
"text": "a.md\nb.md"
},
{
"op": "compact"
},
{
"op": "read",
"id": "#2005",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "output",
"id": "#2006",
"tool": "glob",
"args": {
"pattern": "*.md"
},
"text": "a.md\nb.md"
},
{
"op": "recall",
"id": "#2007",
"target": "#2002"
}
],
"expect": [
"[#2001 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
"[#2002 b.md lines 1-1 of 1 sha f2c82dec]\n1: beta",
"[#2003 a.md lines 1-1 of 1 sha b6a98d9c] Unchanged since #2001, which already shows lines 1-1.",
"[#2004]\na.md\nb.md",
"## Files read before this summary\n\nRead a file again for its content; recall shows a result by its id.\n\n| Path | Lines | sha | Result |\n|---|---|---|---|\n| a.md | 1-1 of 1 | b6a98d9c | #2001 |\n| b.md | 1-1 of 1 | f2c82dec | #2002 |",
"[#2005 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
"[#2006]\na.md\nb.md",
"[#2007 recall #2002 lines 1-1 of 1]\n1: beta"
]
},
{
"steps": [
{
"op": "compact"
},
{
"op": "recall",
"id": "#3001",
"target": "#ffff"
}
],
"expect": [
"",
"[#3001] No stored result #ffff in this session."
]
},
{
"steps": [
{
"op": "write",
"id": "#4001",
"path": "new.md",
"after": "# Title\n\nText.\n"
},
{
"op": "read",
"id": "#4002",
"path": "empty.txt",
"content": ""
}
],
"expect": [
"[#4001 new.md sha fd9dba9b] Created, 3 lines.",
"[#4002 empty.txt empty sha e3b0c442]\n(empty file)"
]
},
{
"limits": {
"max_lines": 400,
"max_chars": 50,
"head_lines": 200,
"head_chars": 20,
"tail_lines": 100,
"tail_chars": 10
},
"steps": [
{
"op": "output",
"id": "#5001",
"tool": "bash",
"args": {
"command": "cat min.js"
},
"text": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
{
"op": "output",
"id": "#5002",
"tool": "bash",
"args": {
"command": "cat log"
},
"text": "first line\nyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\nlast"
}
],
"expect": [
"[#5001]\nxxxxxxxxxxxxxxxxxxxx […cut]\n[0 lines elided, full output #5001]",
"[#5002]\nfirst line\n[1 lines elided, full output #5002]\nlast"
]
},
{
"steps": [
{
"op": "read",
"id": "#6001",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "output",
"id": "#6002",
"tool": "glob",
"args": {
"pattern": "*.md"
},
"text": "a.md"
},
{
"op": "reply"
},
{
"op": "read",
"id": "#6003",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "output",
"id": "#6004",
"tool": "glob",
"args": {
"pattern": "*.md"
},
"text": "a.md"
},
{
"op": "read",
"id": "#6005",
"path": "a.md",
"content": "alpha\n"
},
{
"op": "read",
"id": "#6006",
"path": "b.md",
"content": "beta\n"
},
{
"op": "compact"
},
{
"op": "read",
"id": "#6007",
"path": "c.md",
"content": "gamma\n"
},
{
"op": "reply"
},
{
"op": "compact"
}
],
"expect": [
"[#6001 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
"[#6002]\na.md",
"",
"[#6003 a.md lines 1-1 of 1 sha b6a98d9c]\n1: alpha",
"[#6004]\na.md",
"[#6005 a.md lines 1-1 of 1 sha b6a98d9c] Unchanged since #6003, which already shows lines 1-1.",
"[#6006 b.md lines 1-1 of 1 sha f2c82dec]\n1: beta",
"## Files read before this summary\n\nRead a file again for its content; recall shows a result by its id.\n\n| Path | Lines | sha | Result |\n|---|---|---|---|\n| a.md | 1-1 of 1 | b6a98d9c | #6003 |\n| b.md | 1-1 of 1 | f2c82dec | #6006 |",
"[#6007 c.md lines 1-1 of 1 sha ae9a6306]\n1: gamma",
"",
"## Files read before this summary\n\nRead a file again for its content; recall shows a result by its id.\n\n| Path | Lines | sha | Result |\n|---|---|---|---|\n| c.md | 1-1 of 1 | ae9a6306 | #6007 |"
]
}
]
}
+876
View File
@@ -0,0 +1,876 @@
{
"about": "The file system as one session sees it (filesystem.md): `tree` (paths; a directory ends with `/`, parents are implied), `mounts` (directories that are forge repositories), `grants` (path → level none/ro/stage/commit, covering everything below; the most specific wins; none by default), `floor` (path → the most a session may have there; the most specific applies), `cwd` (the start directory, default `/`), and one `op` on `path`: `level` (the session's level there, and whether it is a passage: at none with something granted below), `list` (the entries the session sees: directories and mounts with `/`, a mount ` [mount]`, an entry at ro in a writable directory ` [read-only]`; directories first, then by name), `read`, `write` (a file), `mkdir`, `rm`, `commit`. Answers `{ok: true}`, `{entries}`, `{level, passage}` or `{error}` with the exact text, naming the resolved absolute path. A path the session cannot see is answered by what the model already knows: by its nearest ancestor that is visible (No such file or directory) or a passage (Access denied) — so a hidden entry and a missing one read the same; creating a name taken by an entry at none is denied. LLeMbas: services/fs.py (the view) with security/access.py (levels and grants); CLI: not run until it has a view of its own (an agent's disk is one level for the whole working directory).",
"cases": [
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "level",
"path": "/Dir1",
"expect": {
"level": "none",
"passage": true
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "level",
"path": "/Dir2",
"expect": {
"level": "none",
"passage": false
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "level",
"path": "/Dir1/Dir3/a.md",
"expect": {
"level": "stage",
"passage": false
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "list",
"path": "/Dir1",
"expect": {
"error": "Access denied: /Dir1 is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "list",
"path": "/",
"expect": {
"error": "Access denied: / is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Dir1/FILE1.txt",
"expect": {
"error": "Access denied: /Dir1/FILE1.txt is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "/Dir1/new.txt",
"expect": {
"error": "Access denied: /Dir1/new.txt is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "mkdir",
"path": "/Dir1/x",
"expect": {
"error": "Access denied: /Dir1/x is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Dir2/b.md",
"expect": {
"error": "Access denied: /Dir2/b.md is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Dir2",
"expect": {
"error": "Access denied: /Dir2 is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Nope.md",
"expect": {
"error": "Access denied: /Nope.md is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/Dir1/Dir3/missing.md",
"expect": {
"error": "No such file or directory: /Dir1/Dir3/missing.md"
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "/Dir2/x.md",
"expect": {
"error": "Access denied: /Dir2/x.md is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "list",
"path": "/Dir1/Dir3",
"expect": {
"entries": [
"a.md"
]
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "/AGENTS.md",
"expect": {
"ok": true
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "/AGENTS.md",
"expect": {
"error": "Read-only: /AGENTS.md can be read in this session but not changed."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "/Dir1/Dir3/new.md",
"expect": {
"ok": true
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "write",
"path": "new.md",
"cwd": "/Dir1/Dir3",
"expect": {
"ok": true
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "read",
"path": "../FILE1.txt",
"cwd": "/Dir1/Dir3",
"expect": {
"error": "Access denied: /Dir1/FILE1.txt is not open to this session."
}
},
{
"tree": [
"/AGENTS.md",
"/Dir1/",
"/Dir1/FILE1.txt",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/",
"/Dir1/Dir3/a.md",
"/Dir2/",
"/Dir2/b.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"op": "list",
"path": "/Dir1/Dir3/../../Dir1//Dir3/",
"expect": {
"entries": [
"a.md"
]
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "list",
"path": "/",
"expect": {
"entries": [
"Notes/",
"Ref/ [read-only]"
]
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "list",
"path": "/Notes",
"expect": {
"entries": [
"a.md"
]
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "read",
"path": "/Notes/Private/p.md",
"expect": {
"error": "No such file or directory: /Notes/Private/p.md"
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "mkdir",
"path": "/Notes/Private",
"expect": {
"error": "Access denied: /Notes/Private is not open to this session."
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "write",
"path": "/Notes/Private/x.md",
"expect": {
"error": "Access denied: /Notes/Private/x.md is not open to this session."
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "rm",
"path": "/Notes/Private",
"expect": {
"error": "No such file or directory: /Notes/Private"
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "rm",
"path": "/Notes/gone.md",
"expect": {
"error": "No such file or directory: /Notes/gone.md"
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "write",
"path": "/Ref/r.md",
"expect": {
"error": "Read-only: /Ref/r.md can be read in this session but not changed."
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "write",
"path": "/Ref/new.md",
"expect": {
"error": "Read-only: /Ref/new.md can be read in this session but not changed."
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "stage",
"/Notes/Private": "none",
"/Ref": "ro"
},
"op": "rm",
"path": "/Notes/a.md",
"expect": {
"ok": true
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "commit"
},
"floor": {
"/Ref": "ro"
},
"op": "level",
"path": "/Ref/r.md",
"expect": {
"level": "ro",
"passage": false
}
},
{
"tree": [
"/Notes/",
"/Notes/a.md",
"/Notes/Private/",
"/Notes/Private/p.md",
"/Ref/",
"/Ref/r.md"
],
"grants": {
"/": "commit"
},
"floor": {
"/Ref": "ro"
},
"op": "write",
"path": "/Ref/r.md",
"expect": {
"error": "Read-only: /Ref/r.md can be read in this session but not changed."
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit",
"/repo": "stage"
},
"op": "list",
"path": "/",
"expect": {
"entries": [
"home/",
"repo/ [mount]"
]
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit",
"/repo": "stage"
},
"op": "commit",
"path": "/repo/src/x.ts",
"expect": {
"error": "Not committed: this session may change /repo/src/x.ts but not commit it; the change stays staged."
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "commit",
"path": "/home/n.md",
"expect": {
"error": "Nothing to commit: /home/n.md is not in a repository."
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/repo": "commit"
},
"op": "commit",
"path": "/repo/README.md",
"expect": {
"ok": true
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/repo": "ro"
},
"op": "commit",
"path": "/repo/README.md",
"expect": {
"error": "Read-only: /repo/README.md can be read in this session but not changed."
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "write",
"path": "/repo/src",
"expect": {
"error": "Is a directory: /repo/src"
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "write",
"path": "/home/n.md/x",
"expect": {
"error": "Not a directory: /home/n.md"
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "mkdir",
"path": "/home/n.md",
"expect": {
"error": "Already exists: /home/n.md"
}
},
{
"tree": [
"/repo/",
"/repo/README.md",
"/repo/src/",
"/repo/src/x.ts",
"/home/",
"/home/n.md"
],
"mounts": [
"/repo"
],
"grants": {
"/": "commit"
},
"op": "mkdir",
"path": "/home",
"expect": {
"ok": true
}
}
]
}
+129
View File
@@ -0,0 +1,129 @@
{
"about": "The instruction files a session starts with (filesystem.md, Instruction files, stacked): `files` that exist, the `boundary` and the `start` directory (outside the boundary: read as the boundary), and `grants` as in fs_access (absent: everything readable). From the boundary down to the start directory, outermost first, each directory gives the first of AGENTS.md, CLAUDE.md that is a file the session can read — never both; a file at none (in a passage, or taken away) is not loaded and the walk goes on below it. The answer is the loaded paths, in order. The CLI's global AGENTS.md, before the chain, and its configured `instructions`, after it, are not part of these cases. LLeMbas: services/workspace.py chain; CLI: prompt/assemble.ts instructionChain.",
"cases": [
{
"files": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
],
"boundary": "/",
"start": "/sec/repo",
"expect": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md"
]
},
{
"files": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
],
"boundary": "/",
"start": "/sec/repo/src",
"expect": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
]
},
{
"files": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
],
"boundary": "/sec",
"start": "/sec/repo",
"expect": [
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md"
]
},
{
"files": [
"/AGENTS.md",
"/sec/AGENTS.md",
"/sec/repo/CLAUDE.md",
"/sec/repo/src/AGENTS.md"
],
"boundary": "/sec",
"start": "/other",
"expect": [
"/sec/AGENTS.md"
]
},
{
"files": [
"/x/AGENTS.md",
"/x/CLAUDE.md"
],
"boundary": "/",
"start": "/x",
"expect": [
"/x/AGENTS.md"
]
},
{
"files": [
"/AGENTS.md",
"/Dir1/AGENTS.md",
"/Dir1/Dir3/AGENTS.md"
],
"grants": {
"/AGENTS.md": "ro",
"/Dir1/Dir3": "stage"
},
"boundary": "/",
"start": "/Dir1/Dir3",
"expect": [
"/AGENTS.md",
"/Dir1/Dir3/AGENTS.md"
]
},
{
"files": [
"/x/AGENTS.md",
"/x/CLAUDE.md"
],
"grants": {
"/": "ro",
"/x/AGENTS.md": "none"
},
"boundary": "/",
"start": "/x",
"expect": [
"/x/CLAUDE.md"
]
},
{
"files": [
"/AGENTS.md",
"/a/AGENTS.md",
"/a/b/AGENTS.md"
],
"grants": {
"/a/b": "ro"
},
"boundary": "/",
"start": "/a/b",
"expect": [
"/a/b/AGENTS.md"
]
},
{
"files": [
"/README.md"
],
"boundary": "/",
"start": "/",
"expect": []
}
]
}
+337
View File
@@ -0,0 +1,337 @@
{
"about": "The effort levels a chat offers for a model, and what each sends (models/schema.json `levels`, spec 3.2.0). `op: levels` — `model` is model metadata (`thinking_switch`, `efforts` as given — never absent here, since an absent list is each project's own fallback — `effort`); the answer is `levels` (in offering order), `control` (`none`: one level, no effort control; `switch`: two levels, one of them off — a Thinking on/off switch; `slider`: anything else, a slider or a list) and `start` (the level a new conversation starts with: `effort` when it is one of the levels, else `medium` when offered, else the first effort, else `default`). `op: request` — `level` is the level asked for; the answer is what an OpenAI chat request body carries for it: `reasoning_effort` and `chat_template_kwargs` (only the keys set; `{}` when nothing). A level the model does not offer is read as the start level. LLeMbas: services/generation.py; CLI: provider/effort.ts.",
"cases": [
{
"about": "A model with the switch and one effort: a Thinking switch, on at its effort.",
"op": "levels",
"model": {
"thinking_switch": true,
"efforts": [
"medium"
]
},
"expect": {
"levels": [
"off",
"medium"
],
"control": "switch",
"start": "medium"
}
},
{
"about": "The same effort without the switch: one level, no control at all.",
"op": "levels",
"model": {
"efforts": [
"medium"
]
},
"expect": {
"levels": [
"medium"
],
"control": "none",
"start": "medium"
}
},
{
"about": "The switch and no efforts: off, or the model's own default — still a switch.",
"op": "levels",
"model": {
"thinking_switch": true,
"efforts": []
},
"expect": {
"levels": [
"off",
"default"
],
"control": "switch",
"start": "default"
}
},
{
"about": "Neither: one level, the model's own; nothing is shown and nothing sent.",
"op": "levels",
"model": {
"efforts": []
},
"expect": {
"levels": [
"default"
],
"control": "none",
"start": "default"
}
},
{
"about": "The switch and three efforts: a slider, off first; medium is where it starts when nothing is said.",
"op": "levels",
"model": {
"thinking_switch": true,
"efforts": [
"low",
"medium",
"high"
]
},
"expect": {
"levels": [
"off",
"low",
"medium",
"high"
],
"control": "slider",
"start": "medium"
}
},
{
"about": "Three efforts without the switch: a slider without off.",
"op": "levels",
"model": {
"efforts": [
"low",
"medium",
"high"
]
},
"expect": {
"levels": [
"low",
"medium",
"high"
],
"control": "slider",
"start": "medium"
}
},
{
"about": "The model's own start level, when it offers it.",
"op": "levels",
"model": {
"thinking_switch": true,
"efforts": [
"low",
"medium",
"high"
],
"effort": "high"
},
"expect": {
"levels": [
"off",
"low",
"medium",
"high"
],
"control": "slider",
"start": "high"
}
},
{
"about": "A start of off, with the switch: the conversation starts with thinking off.",
"op": "levels",
"model": {
"thinking_switch": true,
"efforts": [
"medium"
],
"effort": "off"
},
"expect": {
"levels": [
"off",
"medium"
],
"control": "switch",
"start": "off"
}
},
{
"about": "A start of off without the switch is not a level the model offers: the start rule applies (no medium: the first effort).",
"op": "levels",
"model": {
"efforts": [
"low",
"high"
],
"effort": "off"
},
"expect": {
"levels": [
"low",
"high"
],
"control": "slider",
"start": "low"
}
},
{
"about": "thinking_switch false is the same as absent.",
"op": "levels",
"model": {
"thinking_switch": false,
"efforts": [
"high",
"xhigh"
]
},
"expect": {
"levels": [
"high",
"xhigh"
],
"control": "slider",
"start": "high"
}
},
{
"about": "Two efforts and no switch: two levels, neither off — a slider, not a switch.",
"op": "levels",
"model": {
"efforts": [
"low",
"medium"
]
},
"expect": {
"levels": [
"low",
"medium"
],
"control": "slider",
"start": "medium"
}
},
{
"about": "off sends enable_thinking false and no effort anywhere.",
"op": "request",
"model": {
"thinking_switch": true,
"efforts": [
"medium"
]
},
"level": "off",
"expect": {
"chat_template_kwargs": {
"enable_thinking": false
}
}
},
{
"about": "An effort is sent where effort_style puts it (both, by default), and enable_thinking is not sent.",
"op": "request",
"model": {
"thinking_switch": true,
"efforts": [
"medium"
]
},
"level": "medium",
"expect": {
"reasoning_effort": "medium",
"chat_template_kwargs": {
"reasoning_effort": "medium"
}
}
},
{
"about": "effort_style top: the effort at the top level only.",
"op": "request",
"model": {
"thinking_switch": true,
"efforts": [
"low",
"medium"
],
"effort_style": "top"
},
"level": "low",
"expect": {
"reasoning_effort": "low"
}
},
{
"about": "effort_style kwargs: inside chat_template_kwargs only.",
"op": "request",
"model": {
"efforts": [
"low",
"medium"
],
"effort_style": "kwargs"
},
"level": "medium",
"expect": {
"chat_template_kwargs": {
"reasoning_effort": "medium"
}
}
},
{
"about": "default sends nothing: the model's own.",
"op": "request",
"model": {
"thinking_switch": true,
"efforts": []
},
"level": "default",
"expect": {}
},
{
"about": "off, effort_style top: still only enable_thinking, in chat_template_kwargs (the only place a template reads it).",
"op": "request",
"model": {
"thinking_switch": true,
"efforts": [
"medium"
],
"effort_style": "top"
},
"level": "off",
"expect": {
"chat_template_kwargs": {
"enable_thinking": false
}
}
},
{
"about": "off asked of a model without the switch is read as its start level.",
"op": "request",
"model": {
"efforts": [
"low",
"medium",
"high"
]
},
"level": "off",
"expect": {
"reasoning_effort": "medium",
"chat_template_kwargs": {
"reasoning_effort": "medium"
}
}
},
{
"about": "A level the model does not take is read as its start level too.",
"op": "request",
"model": {
"thinking_switch": true,
"efforts": [
"medium"
]
},
"level": "high",
"expect": {
"reasoning_effort": "medium",
"chat_template_kwargs": {
"reasoning_effort": "medium"
}
}
}
]
}
+89
View File
@@ -0,0 +1,89 @@
{
"about": "Where an agent session works, as the model is told it and as its tools read a path (context.md §11, spec 3.2.0). `op: env` — `root` is the project root and `started` the directory the session was started in (both absolute); the answer is the environment block's lines about them, in order, from loop.json context.env — none naming an absolute path. `op: resolve` — `path` as a tool call gives it; the answer is the absolute path it means: relative paths against the project root, whatever `started` is, `.` and `..` by their text (filesystem.md, Paths); an absolute path as it is. LLeMbas: not run — a chat's paths are the server's tree's; CLI: prompt/assemble.ts, tool/tool.ts.",
"cases": [
{
"about": "Started at the root: the project's name and nothing else.",
"op": "env",
"root": "/home/ada/src/app",
"started": "/home/ada/src/app",
"expect": [
"Project: app (paths are relative to its root)"
]
},
{
"about": "Started below the root: that directory, relative to the root.",
"op": "env",
"root": "/home/ada/src/app",
"started": "/home/ada/src/app/packages/web",
"expect": [
"Project: app (paths are relative to its root)",
"Started in: packages/web (relative to the project root)"
]
},
{
"about": "A trailing slash changes nothing.",
"op": "env",
"root": "/srv/work/site/",
"started": "/srv/work/site",
"expect": [
"Project: site (paths are relative to its root)"
]
},
{
"about": "A relative path is resolved against the project root.",
"op": "resolve",
"root": "/home/ada/src/app",
"started": "/home/ada/src/app",
"path": "src/main.ts",
"expect": "/home/ada/src/app/src/main.ts"
},
{
"about": "…also when the session started below the root: one base for every relative path.",
"op": "resolve",
"root": "/home/ada/src/app",
"started": "/home/ada/src/app/packages/web",
"path": "src/main.ts",
"expect": "/home/ada/src/app/src/main.ts"
},
{
"about": "The directory the session started in is reached by its path from the root.",
"op": "resolve",
"root": "/home/ada/src/app",
"started": "/home/ada/src/app/packages/web",
"path": "packages/web/index.ts",
"expect": "/home/ada/src/app/packages/web/index.ts"
},
{
"about": "An absolute path is still accepted, as it is.",
"op": "resolve",
"root": "/home/ada/src/app",
"started": "/home/ada/src/app/packages/web",
"path": "/etc/hosts",
"expect": "/etc/hosts"
},
{
"about": ". is the root.",
"op": "resolve",
"root": "/home/ada/src/app",
"started": "/home/ada/src/app/packages/web",
"path": ".",
"expect": "/home/ada/src/app"
},
{
"about": ".. is resolved by its text (whether it may be reached is the permission rules' business).",
"op": "resolve",
"root": "/home/ada/src/app",
"started": "/home/ada/src/app",
"path": "../lib/x.ts",
"expect": "/home/ada/src/lib/x.ts"
},
{
"about": "./ and repeated slashes are resolved by their text.",
"op": "resolve",
"root": "/home/ada/src/app",
"started": "/home/ada/src/app",
"path": "./docs//guide.md",
"expect": "/home/ada/src/app/docs/guide.md"
}
]
}
+535
View File
@@ -0,0 +1,535 @@
{
"about": "The prefix (context.md §10, spec 3.1.0): one session's requests, in order — which system prompt each sends, which tools it offers, and the note on a user message. `start` is the session as it begins: `date`, `mode`, and what the prompt is made from that may change (`plan` as rendered, `memory`, `personality`, `files` — instruction texts by path or heading — `mcp` — instructions by server; each \"\" or {} for none), `could` (the requirements the session could ever meet: `plan` for any session that may enter plan mode, `display`, `page`, `forge`) and `met` (those met now besides plan, which is met in plan mode; default: all it could). `tools`: the names a case looks at; a tool's `requires` is the spec's. Steps: `user` (a user message: the request that carries it), `tool` (a tool step's request: no user message), `set` (something changes — any of the start's fields, or `met`; `by: model` when the model's own tool did it, whose result told the model: `plan` from plan_write, `mode` from plan_submit's approval, `memory` and `files` it changed itself; otherwise the person or the clock did), `call` (a call of `tool` now), `compact` (the summary is asked for: `{{transcript}}` and `{{previous_summary}}` empty — an earlier summary is already the first message), `wrapup` (the request after a step limit or a budget: the same prompt and tools, the wrap-up message as its last user message, no note; a call in its reply is not run) and `model` (the session moves to another model). A request answers `{system, tools, note}`: `system` numbers the system prompts the session has made, from 1, the same number being the same bytes; `tools` the case's tools it offers; `note` the note after the person's words, or null. `call` answers the tool's `unavailable` text, or null where it runs; `compact` answers the request's prompt, its tools and `ask`, the user message that follows every message as it was; `wrapup` answers like a request; `set` and `model` answer null; a value that became empty reads `context.note.empty`. `plan_dir` fills modes/plan.md. LLeMbas: services/context.py; CLI: session/prefix.ts, session/engine.ts.",
"cases": [
{
"about": "A date that changes during a session is a note on the next user message; the system prompt and the tools stay the same bytes.",
"start": {
"date": "2026-11-04",
"mode": "manual",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "tool"
},
{
"op": "user"
},
{
"op": "set",
"date": "2026-11-05"
},
{
"op": "tool"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-04.\n</session-note>"
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
null,
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
}
]
},
{
"about": "plan_submit is offered in every mode and answers its unavailable text outside plan mode; a mode the person chose is a note with the mode's text, one the approval set is not.",
"start": {
"date": "2026-11-05",
"mode": "manual",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_write",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "call",
"tool": "plan_submit"
},
{
"op": "set",
"mode": "plan"
},
{
"op": "user"
},
{
"op": "call",
"tool": "plan_submit"
},
{
"op": "set",
"mode": "edit",
"by": "model"
},
{
"op": "tool"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
"Not in plan mode: plan_submit is how a plan made in plan mode is put to the person for approval. Carry on with the work; keep the plan current with plan_update.",
null,
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": "<session-note>\nThe permission mode is now plan:\nPermission mode: plan. You are planning, not implementing. Read, search and investigate as much as you need, but change nothing: no file but a plan under .agent/plans, no command that is not already allowed, nothing saved to the library.\n\nWrite the plan with plan_write, so that someone else could follow it: findings with file:line references, what will be true when it is done (objectives), and the work as phases of tasks in order, with the risks and how to verify it as the last phase. Ask with ask_user if a decision is the user's. When the plan is written, call plan_submit — that is how the user approves it, and after approval you carry on and implement it, keeping the plan current with plan_update.\n</session-note>"
},
null,
null,
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_write",
"plan_submit"
],
"note": null
}
]
},
{
"about": "The compaction request is the same prompt and tools, every message, then the compaction text with no transcript; after it the prompt is made again and the next user message says the date again.",
"start": {
"date": "2026-11-05",
"mode": "edit",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "tool"
},
{
"op": "compact"
},
{
"op": "user"
},
{
"op": "compact"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"ask": "Summarise the conversation below so it can be carried forward after the earlier turns are dropped from your context. This is a working record, not a report for a reader.\n\nKeep, under these headings and in this order:\n\n## What we are doing\nThe goal, and where we have got to.\n\n## Decisions\nAnything settled, and why. A decision without its reason gets argued again.\n\n## Facts established\nNames, numbers, versions, file paths, commands, URLs and identifiers, copied exactly. Do not round them, paraphrase them or reconstruct one from memory — if it is not in the transcript, leave it out.\n\n## Files touched\nEach file read or changed that still matters, and what was done to it.\n\n## Open threads\nWhat is unfinished, and what was about to happen next.\n\nLeave out pleasantries, retracted ideas and anything already superseded. Do not answer the conversation: you are recording it. Write in the language of the conversation, and stay under 600 words."
},
{
"system": 2,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
{
"system": 2,
"tools": [
"read",
"plan_submit"
],
"ask": "Summarise the conversation below so it can be carried forward after the earlier turns are dropped from your context. This is a working record, not a report for a reader.\n\nKeep, under these headings and in this order:\n\n## What we are doing\nThe goal, and where we have got to.\n\n## Decisions\nAnything settled, and why. A decision without its reason gets argued again.\n\n## Facts established\nNames, numbers, versions, file paths, commands, URLs and identifiers, copied exactly. Do not round them, paraphrase them or reconstruct one from memory — if it is not in the transcript, leave it out.\n\n## Files touched\nEach file read or changed that still matters, and what was done to it.\n\n## Open threads\nWhat is unfinished, and what was about to happen next.\n\nLeave out pleasantries, retracted ideas and anything already superseded. Do not answer the conversation: you are recording it. Write in the language of the conversation, and stay under 600 words."
}
]
},
{
"about": "What the model changed itself is not said again; what the person changed is, once, in loop.json's order; an MCP server that connected later is said by its own instructions.",
"start": {
"date": "2026-11-05",
"mode": "manual",
"plan": "",
"memory": "Prefers tabs.",
"personality": "",
"files": {
"/p/AGENTS.md": "Build with make."
},
"mcp": {
"docs": "Search the docs first."
},
"could": [
"plan"
]
},
"tools": [
"read"
],
"steps": [
{
"op": "user"
},
{
"op": "set",
"by": "model",
"plan": "# Fix\n## p1 P (pending)\n[t1] [ ] a",
"memory": "Prefers tabs.\n§\nUses fish.",
"files": {
"/p/AGENTS.md": "Build with make; test with make test."
}
},
{
"op": "tool"
},
{
"op": "user"
},
{
"op": "set",
"plan": "",
"memory": "Uses fish.",
"personality": "Be brief.",
"files": {
"/p/AGENTS.md": "Build with make; test with make test.",
"/p/src/AGENTS.md": "Strict types."
},
"mcp": {
"docs": "Search the docs first.",
"tickets": "Name the ticket in every commit."
}
},
{
"op": "user"
},
{
"op": "set",
"files": {
"/p/AGENTS.md": "Build with make; test with make test."
}
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
null,
{
"system": 1,
"tools": [
"read"
],
"note": null
},
{
"system": 1,
"tools": [
"read"
],
"note": null
},
null,
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nThe person changed the plan; it is now:\n(none)\n\nThe person changed what is remembered; it is now:\nUses fish.\n\n/p/src/AGENTS.md changed; it now reads:\nStrict types.\n\nThe person chose a different personality:\nBe brief.\n\nInstructions from an MCP server that connected since the session began:\n<server name=\"tickets\">\nName the ticket in every commit.\n</server>\n</session-note>"
},
null,
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\n/p/src/AGENTS.md changed; it now reads:\n(none)\n</session-note>"
}
]
},
{
"about": "A move to another model makes the prompt again; the date said in the conversation still holds.",
"start": {
"date": "2026-11-05",
"mode": "auto",
"could": [
"plan"
]
},
"tools": [
"read"
],
"steps": [
{
"op": "user"
},
{
"op": "model"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
null,
{
"system": 2,
"tools": [
"read"
],
"note": null
}
]
},
{
"about": "A display the session could have is offered from the start and answers unavailable while there is none; one it never could have leaves show out.",
"start": {
"date": "2026-11-05",
"mode": "manual",
"could": [
"plan",
"display"
],
"met": []
},
"tools": [
"read",
"show"
],
"steps": [
{
"op": "user"
},
{
"op": "call",
"tool": "show"
},
{
"op": "set",
"met": [
"display"
]
},
{
"op": "call",
"tool": "show"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"show"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
"Nothing can be shown now: this session has no screen to show a file on. Give the user the path instead.",
null,
null
]
},
{
"start": {
"date": "2026-11-05",
"mode": "manual",
"could": [
"plan"
]
},
"tools": [
"read",
"show"
],
"steps": [
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
}
]
},
{
"about": "The wrap-up keeps the session's tool list and prompt, carries no note, and the next user message carries what changed meanwhile.",
"start": {
"date": "2026-11-05",
"mode": "edit",
"could": [
"plan"
]
},
"tools": [
"read",
"plan_submit"
],
"steps": [
{
"op": "user"
},
{
"op": "tool"
},
{
"op": "set",
"date": "2026-11-06"
},
{
"op": "wrapup"
},
{
"op": "user"
}
],
"expect": [
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-05.\n</session-note>"
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
null,
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": null
},
{
"system": 1,
"tools": [
"read",
"plan_submit"
],
"note": "<session-note>\nToday is 2026-11-06.\n</session-note>"
}
]
}
]
}
+323
View File
@@ -0,0 +1,323 @@
{
"about": "What a request sends back of the model's reasoning (context.md §8, spec 3.2.0). `messages` is the history a request is built from, in order: `role` user, assistant or tool; `content`; an assistant message's stored `reasoning` (absent: it came with none); a user message's `turn` — false for a delivery at a step boundary (a job's note, a team message, words added to a running turn), which does not start a turn; absent or true for one that does (the person's, or a machine message starting a turn of its own). The answer is the messages as the OpenAI chat dialect sends them — `role`, `content`, and `reasoning_content` on an assistant message after the last user message that started a turn, when its reasoning is not empty (tool calls and ids are left out of the cases: they are sent as always). The Anthropic dialect sends the same messages' thinking blocks. LLeMbas: services/llm/openai_client.py, services/generation.py; CLI: provider/openai-chat.ts, session/engine.ts.",
"cases": [
{
"about": "One turn, three steps: every reply of the turn carries its reasoning back.",
"messages": [
{
"role": "user",
"content": "Fix the failing test."
},
{
"role": "assistant",
"content": "",
"reasoning": "The test name points at the parser."
},
{
"role": "tool",
"content": "[#0001 src/parse.ts lines 1-40 of 40 sha 1c9e04ab]\n…"
},
{
"role": "assistant",
"content": "",
"reasoning": "Line 12 drops the last token."
},
{
"role": "tool",
"content": "[#0002 src/parse.ts sha 7d0a91c3]\n@@ -12 +12 @@"
}
],
"expect": [
{
"role": "user",
"content": "Fix the failing test."
},
{
"role": "assistant",
"content": "",
"reasoning_content": "The test name points at the parser."
},
{
"role": "tool",
"content": "[#0001 src/parse.ts lines 1-40 of 40 sha 1c9e04ab]\n…"
},
{
"role": "assistant",
"content": "",
"reasoning_content": "Line 12 drops the last token."
},
{
"role": "tool",
"content": "[#0002 src/parse.ts sha 7d0a91c3]\n@@ -12 +12 @@"
}
]
},
{
"about": "A second turn: the first turn's replies go without their reasoning, the current one's with it.",
"messages": [
{
"role": "user",
"content": "Where is the config loaded?"
},
{
"role": "assistant",
"content": "",
"reasoning": "Search for load."
},
{
"role": "tool",
"content": "[#0001] src/config/load.ts:40"
},
{
"role": "assistant",
"content": "In src/config/load.ts.",
"reasoning": "Found it at line 40."
},
{
"role": "user",
"content": "And who calls it?"
},
{
"role": "assistant",
"content": "",
"reasoning": "Grep for loadConfig."
},
{
"role": "tool",
"content": "[#0002] src/app.ts:120"
}
],
"expect": [
{
"role": "user",
"content": "Where is the config loaded?"
},
{
"role": "assistant",
"content": ""
},
{
"role": "tool",
"content": "[#0001] src/config/load.ts:40"
},
{
"role": "assistant",
"content": "In src/config/load.ts."
},
{
"role": "user",
"content": "And who calls it?"
},
{
"role": "assistant",
"content": "",
"reasoning_content": "Grep for loadConfig."
},
{
"role": "tool",
"content": "[#0002] src/app.ts:120"
}
]
},
{
"about": "A job's note delivered at a step boundary does not start a turn: the turn's earlier replies keep their reasoning.",
"messages": [
{
"role": "user",
"content": "Run the tests in the background and fix the lint."
},
{
"role": "assistant",
"content": "",
"reasoning": "Start the tests, then lint."
},
{
"role": "tool",
"content": "[#0001] job1 started"
},
{
"role": "user",
"content": "[A background job you started has finished:]\njob1 (npm test) exited with 0 after 5s.",
"turn": false
},
{
"role": "assistant",
"content": "",
"reasoning": "Tests pass; now the lint."
},
{
"role": "tool",
"content": "[#0002] 0 problems"
}
],
"expect": [
{
"role": "user",
"content": "Run the tests in the background and fix the lint."
},
{
"role": "assistant",
"content": "",
"reasoning_content": "Start the tests, then lint."
},
{
"role": "tool",
"content": "[#0001] job1 started"
},
{
"role": "user",
"content": "[A background job you started has finished:]\njob1 (npm test) exited with 0 after 5s."
},
{
"role": "assistant",
"content": "",
"reasoning_content": "Tests pass; now the lint."
},
{
"role": "tool",
"content": "[#0002] 0 problems"
}
]
},
{
"about": "A machine message that starts a turn of its own (a team report to an idle lead) does: the replies before it go without.",
"messages": [
{
"role": "user",
"content": "Split the work."
},
{
"role": "assistant",
"content": "Started alice.",
"reasoning": "Add one member."
},
{
"role": "user",
"content": "[Team report from alice to lead:]\nDone.",
"turn": true
},
{
"role": "assistant",
"content": "",
"reasoning": "Review alice's change."
},
{
"role": "tool",
"content": "[#0001 src/a.ts lines 1-10 of 10 sha 52b6e3a0]\n…"
}
],
"expect": [
{
"role": "user",
"content": "Split the work."
},
{
"role": "assistant",
"content": "Started alice."
},
{
"role": "user",
"content": "[Team report from alice to lead:]\nDone."
},
{
"role": "assistant",
"content": "",
"reasoning_content": "Review alice's change."
},
{
"role": "tool",
"content": "[#0001 src/a.ts lines 1-10 of 10 sha 52b6e3a0]\n…"
}
]
},
{
"about": "A reply that came with no reasoning, or an empty one, carries no field.",
"messages": [
{
"role": "user",
"content": "Hello"
},
{
"role": "assistant",
"content": "",
"reasoning": "Greet."
},
{
"role": "tool",
"content": "[#0001] ok"
},
{
"role": "assistant",
"content": "Hi."
},
{
"role": "user",
"content": "Again"
},
{
"role": "assistant",
"content": "Hi again.",
"reasoning": ""
}
],
"expect": [
{
"role": "user",
"content": "Hello"
},
{
"role": "assistant",
"content": ""
},
{
"role": "tool",
"content": "[#0001] ok"
},
{
"role": "assistant",
"content": "Hi."
},
{
"role": "user",
"content": "Again"
},
{
"role": "assistant",
"content": "Hi again."
}
]
},
{
"about": "The last message the person wrote starts the turn even when nothing has answered it yet: every earlier reply goes without.",
"messages": [
{
"role": "user",
"content": "One"
},
{
"role": "assistant",
"content": "Done.",
"reasoning": "Easy."
},
{
"role": "user",
"content": "Two"
}
],
"expect": [
{
"role": "user",
"content": "One"
},
{
"role": "assistant",
"content": "Done."
},
{
"role": "user",
"content": "Two"
}
]
}
]
}
File diff suppressed because it is too large. Load diff
+288
View File
@@ -0,0 +1,288 @@
# Context hygiene
Spec 3.2.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. (From 3.2.0 a turn's reasoning is sent back during the turn and
not after it, §8: the common chat templates render the history the same either way.)
## 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. Reasoning goes back to the model during its turn
From 3.2.0 (it was left to the chat template before). A model that thinks before each tool call
works on from what it thought: sent its own reasoning back, it carries on; sent its calls without it,
it reasons its way to them again, step after step. Measured on a local model: a step re-read about a
fifth fewer tokens with its reasoning sent back.
- **The current turn** is every message after the last one that started a turn: a message the
person wrote, or a machine message that starts a turn of its own (a job's or a team's note to an
idle session, `loop.json` `jobs`). What is delivered at a step boundary while a turn runs — a
job's note, a team message, words the person adds — does not start a turn, and a tool result
never does.
- **Every assistant message of the current turn carries the reasoning it came with**, as the
model gave it, in full: in the OpenAI chat dialect as `reasoning_content` on the assistant message
(beside its `content` and `tool_calls`), and in the Anthropic dialect as its thinking blocks with
their signatures, unchanged and first (which that API already requires for tool use). Reasoning
read from inline tags (`<think>`; `quirks.json` `reasoning-fields`) goes back the same way, as
`reasoning_content`, and never as text in `content`. A message that came with no reasoning
carries none — no empty field.
- **Assistant messages of earlier turns carry none.** The common templates (Qwen3.x, Nemotron's
`truncate_history_thinking`, gpt-oss) drop it there themselves, so what the server reads of the
history is the same bytes either way and nothing is re-read; with a template that would keep it,
the turn boundary re-reads at most the previous turn. A compaction's summary starts the history
afresh: nothing before it carries reasoning.
- **What is stored is unchanged**: a session keeps each message's reasoning as before; this rule
is only about what a request carries (`conformance/reasoning.json`).
- A subagent, a team member and the main agent each have their own turns: a member's turn starts
with its brief or with a message that woke it.
## 11. No absolute paths in the prompt
From 3.2.0. An absolute path costs tokens on every request, says where a machine keeps its files,
and is copied by the model into calls where a relative one would do. So in an agent session (a
device's disk):
- **The system prompt, every note and the harness's own task messages (`tasks/init.md`) name no
absolute path of the machine.** The environment
block names the project by its root's last path component and says paths are relative to its
root (`loop.json` `context.env`): `Project: app (paths are relative to its root)`. Where the
session was started in a directory below the root, one more line names that directory relative
to the root: `Started in: packages/web (relative to the project root)`. There is no
`Working directory:` or `Project root:` line with an absolute path any more.
- **A file in the project is named relative to its root** (an instruction file, `docs/AGENTS.md`); a
file under the person's home directory outside the project as `~/…` (the global instructions,
`~/.config/lembas/AGENTS.md`). Only a file outside both is named by its absolute path.
- **Relative paths are resolved against the project root** — by every file tool (`read`, `write`,
`edit`, `multiedit`, `apply_patch`, `glob`, `grep`, `list`, `view_image`, `show`) and as the
directory `bash` starts in (its `cwd` argument, relative, likewise). Where the person started
below the root is a fact told to the model, not a second base; it still ends the instruction
chain (`filesystem.md`). **Absolute paths are still accepted** everywhere, as before, and answers
still name paths as `filesystem.md` says.
- A chat on the server is not a machine's disk: its paths are the server's tree's, short and the
same for every instance (`/home/<name>`, `blocks/workspace.md`). Its prompt names nothing longer.
`conformance/paths.json`: the environment lines, and how a path is resolved.
## 9. What the model is told
`prompts/blocks/context.md`, in every session that has `recall`.
## Not in 3.2.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.
+269
View File
@@ -0,0 +1,269 @@
# 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) —
on a device, from spec 3.2.0, against the project root (*Node kinds*, below).
- `.` 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. From spec 3.2.0 a
device resolves relative paths against the **project root**, not the start directory (`context.md`
§11): the model is told the project and, when it started below the root, that directory relative to
the root — one base for every relative path, whatever the directory. The start directory still
ends the instruction chain (below).
## 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`.
+165 -2
View File
@@ -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": {
@@ -34,7 +34,7 @@
"about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.", "about": "Calls in one step run together, at most 4; a call that waits on the user (ask_user, plan_submit) runs alone; two calls on one file run in order. A tool's error is its result, never the end of the prompt.",
"max": 4 "max": 4
}, },
"capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it serves one request at a time (models/schema.json capacity).", "capacity": "A subagent (task) is not started on a model whose server cannot hold it beside the prompt's own model — or beside the model of a subagent already running: another model of a connection that holds one model at a time, or the same model when it already serves as many of this session's requests as it can at once (models/schema.json capacity: `parallel`, `single_session` being parallel 1). A team's members are not refused there: they wait for a slot (team.md, Capacity).",
"jobs": { "jobs": {
"about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).", "about": "Background work owned by a session: a command (bash `background: true`, or one still running when its timeout passed) or a subagent (task `background: true`). One id sequence per session (job1, job2, …), one list (job_list), one way to read (job_output), to stop (job_stop) and, for a subagent, to message (job_send); a subagent's own tools see only the jobs it started. A job that ends wakes the session that owns it: while a prompt runs, as a user message at the next step boundary; otherwise as a new turn of its own, marked `machine` so a UI shows it as the harness's, not the person's. A session that is not open keeps the note until it is resumed. Every such message begins with its mark, said rather than hidden: a model refuses an unmarked one as an injection (conformance/jobs.json gives the texts).",
"marks": { "marks": {
@@ -60,5 +60,168 @@
"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."
},
"team": {
"about": "Teams (spec 3.2.0, team.md): a lead and the members it adds, who talk in the team's room. The texts are said word for word; {{size}} is the team's size after the call (team.md §3), {{names}} names joined with \", \" in tree order. conformance/team.json.",
"agents": {
"about": "The chat setting agents {min, max}: the agents a team may have, the lead included. 1 ≤ min ≤ max ≤ cap. min and max are a new chat's defaults; cap is the instance's (LLeMbas) or the global configuration's (the CLI).",
"min": 1,
"max": 4,
"cap": 32
},
"names": {
"about": "A member's name: unique in the team; `lead` is the lead's, `all` addresses everyone.",
"pattern": "^[a-z][a-z0-9-]{0,31}$",
"lead": "lead",
"reserved": [
"all",
"lead"
]
},
"marks": {
"message": "[Team message from {{from}} to {{to}}:]",
"report": "[Team report from {{from}} to {{to}}:]"
},
"unavailable": {
"about": "What a team tool answers while its requirement `team` is not met (team.md §2). one_agent is also each team tool's own `unavailable`, and what task answers while a team is offered, max is 1 and the lead has no member.",
"one_agent": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"one_request": "This model serves one request at a time now, so there is no team. Do the work yourself."
},
"work": {
"about": "What a manager may not do itself (team.md §6): every call whose risk class is write or execute — an MCP tool's is read when it says readOnlyHint, else execute — but `job_stop`, and a `bash` call that the default rules alone allow in manual mode (reading commands). The tool list is unchanged; these answer texts.manager.",
"write": [
"apply_patch",
"edit",
"mkdir",
"multiedit",
"mv",
"rm",
"skill_manage",
"write"
],
"execute": [
"bash",
"branch",
"commit",
"merge",
"pr",
"python",
"release",
"tag"
],
"kept": [
"job_stop"
]
},
"read_max": 50,
"texts": {
"added": "Added {{name}} ({{role}}): it is working. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.",
"added_waiting": "Added {{name}} ({{role}}): it waits for a free slot — the model serves {{parallel}} requests at once and all are busy — and starts when one frees. Its report comes to you when it finishes. The team is {{size}} of {{max}} agents.",
"sent": "Sent to {{to}}.",
"stopped": "Stopped {{names}}. The team is {{size}} of {{max}} agents.",
"manager": "You lead a team: give this to a member (team_add or team_send).",
"full": "The team is full: it has as many agents as the person allows ({{max}}). Give the work to a member already on it (team_send), or stop one (team_stop) first.",
"bad_name": "{{name}} cannot be a name: use lowercase letters, digits and hyphens, beginning with a letter, at most 32 characters.",
"all_name": "all addresses the whole team; it cannot be a name.",
"taken": "There is already an agent called {{name}} in this team. Choose another name.",
"no_goal": "Say the team's goal in `goal`, in one line, with your first member: every member is told it.",
"unknown": "No agent called {{name}} in this team. The team: {{names}}.",
"self": "That is you: send it to someone else.",
"nobody": "Nobody else is on the team yet: add a member with team_add.",
"not_yours": "{{name}} is not under you: you can stop only the members you lead, directly or through your members.",
"nobody_to_stop": "No member is under you: there is nobody to stop.",
"no_report": "(no report)",
"ended": "[ended: {{reason}}]"
},
"read": {
"about": "team_read's answer: the tree, then the room. Agents in tree order (an agent, then its members in the order they were added, depth first), indented two spaces per level below the lead. `last`: `active now` while working, else how long ago its last turn ended (durations as jobs write them: \"5s ago\", \"1m 05s ago\"). A message's text: its first line after the colon, every further line indented two spaces. Messages above `since` the caller may see, oldest first, at most read_max; `more` when there are others after the last shown.",
"tree": "Team ({{size}} of {{max}} agents):",
"agent": "{{indent}}- {{name}} — {{role}} · {{state}} · {{model}} · {{last}}",
"lead_role": "leads the team",
"states": {
"working": "working",
"waiting": "waiting for a slot",
"finished": "finished",
"idle": "idle"
},
"active": "active now",
"room": "Room since #{{since}}:",
"message": "#{{n}} {{from}} → {{to}}: {{text}}",
"report": "#{{n}} {{from}} → {{to}} (report): {{text}}",
"none": "No messages since #{{since}}.",
"more": "More after #{{last}}: team_read with since {{last}}."
}
},
"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": "Spec 3.2.0 (context.md §8): every assistant message of the current turn — the messages after the last one that started a turn (the person's, or a machine message that starts a turn of its own; a delivery at a step boundary does not) — carries the reasoning it came with (OpenAI chat: `reasoning_content`; Anthropic: its thinking blocks, unchanged); assistant messages of earlier turns carry none. What is stored keeps it. conformance/reasoning.json.",
"env": {
"about": "Spec 3.2.0 (context.md §11): the environment block's lines about where the session works, in an agent session. `project` always, `name` being the project root's last path component; `started` only when the session started in a directory below the root, `path` being that directory relative to the root. No absolute path. conformance/paths.json.",
"project": "Project: {{name}} (paths are relative to its root)",
"started": "Started in: {{path}} (relative to the project root)"
},
"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. Spec 3.2.0, where a team is offered (team.md §10): `team` — the size setting, on the first user message and after it changes, followed in the same block by `team_lead` when min ≥ 2 ({{members}} = min − 1), or `team_one` in its place when max is 1; `team_stopped` — members the person stopped, a block of its own.",
"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}}",
"team": "The team may have {{min}} to {{max}} agents, you included.",
"team_lead": "Work through a team from the start: you lead, and members do the work — at least {{members}} of them.",
"team_one": "The person allows one agent: work alone; the team tools and task are unavailable.",
"team_stopped": "The person stopped {{names}}; they have left the team.",
"empty": "(none)"
},
"mcp_wait_ms": 3000
} }
} }
+9 -3
View File
@@ -12,8 +12,9 @@
"max_output": { "type": "integer", "minimum": 1, "description": "The most tokens one reply may have." }, "max_output": { "type": "integer", "minimum": 1, "description": "The most tokens one reply may have." },
"temperature": { "type": "number", "minimum": 0, "maximum": 2, "description": "The sampling temperature the model is set up with. LLeMbas: the administrator's, which it also applies to a request that does not set one." }, "temperature": { "type": "number", "minimum": 0, "maximum": 2, "description": "The sampling temperature the model is set up with. LLeMbas: the administrator's, which it also applies to a request that does not set one." },
"top_p": { "type": "number", "minimum": 0, "maximum": 1, "description": "Nucleus sampling, as temperature." }, "top_p": { "type": "number", "minimum": 0, "maximum": 1, "description": "Nucleus sampling, as temperature." },
"efforts": { "type": "array", "items": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max"] }, "description": "The reasoning efforts the model takes, in offering order. Narrowed when a server refuses one (quirks.json effort-refused)." }, "efforts": { "type": "array", "items": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max"] }, "description": "The reasoning efforts the model takes, in offering order. Narrowed when a server refuses one (quirks.json effort-refused). Absent: each project's fallback (the CLI's: low, medium, high); [] — the model takes none, and its one effort level is `default`." },
"effort": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max", "off"], "description": "The effort a new conversation starts with; off sends none." }, "thinking_switch": { "type": "boolean", "description": "Spec 3.2.0: the model's server turns thinking off for a request that sends `chat_template_kwargs.enable_thinking: false` (Qwen3.x and Nemotron 3.x templates on llama.cpp, vLLM, SGLang). Only then is the level `off` offered. Absent: false." },
"effort": { "enum": ["minimal", "low", "medium", "high", "xhigh", "max", "off", "default"], "description": "The level a new conversation starts with, when it is one of the levels the model offers (see `levels`); otherwise `medium` when the model takes it, else its first effort, else `default`. `off` — thinking switched off — exists only with thinking_switch; `default` — no effort sent, the model's own — only for a model that takes no efforts." },
"effort_style": { "enum": ["top", "kwargs", "both"], "description": "OpenAI Chat dialect: where the effort goes — top-level `reasoning_effort`, `chat_template_kwargs.reasoning_effort` (all llama.cpp reads), or both (the default)." }, "effort_style": { "enum": ["top", "kwargs", "both"], "description": "OpenAI Chat dialect: where the effort goes — top-level `reasoning_effort`, `chat_template_kwargs.reasoning_effort` (all llama.cpp reads), or both (the default)." },
"vision": { "type": "boolean", "description": "Takes images. Never sent images otherwise: most servers refuse the whole request." }, "vision": { "type": "boolean", "description": "Takes images. Never sent images otherwise: most servers refuse the whole request." },
"tools": { "type": "boolean", "description": "Takes tool calls. false: it can only talk; never sent a tools array." }, "tools": { "type": "boolean", "description": "Takes tool calls. false: it can only talk; never sent a tools array." },
@@ -23,11 +24,16 @@
"description": "How much the model's server can do at once, so a helper or a subagent on it is not started where it would evict or wait for the model already working.", "description": "How much the model's server can do at once, so a helper or a subagent on it is not started where it would evict or wait for the model already working.",
"properties": { "properties": {
"group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one. The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." }, "group": { "type": "string", "description": "Models that share one server holding one model at a time (llama-swap): starting another model of the group unloads this one. The CLI: a model's `group`, or its connection when the connection sets one_model_at_a_time. LLeMbas: the connection's name when it sets one_model_at_a_time, which is what /v1/models says." },
"single_session": { "type": "boolean", "description": "The model serves one request at a time: a second one waits behind the first." } "single_session": { "type": "boolean", "description": "The model serves one request at a time: a second one waits behind the first. The same as `parallel: 1`, which a server that knows `parallel` sends beside it for older clients." },
"parallel": { "type": "integer", "minimum": 1, "description": "Spec 3.2.0: how many requests the model's server serves at once (llama.cpp's slots, a vLLM or SGLang batch); one more waits behind them. Read as: `parallel` when given, else 1 when single_session is true, else no limit (a hosted API). Where both are given and disagree, `parallel` wins. A team (team.md) is offered only on a model that serves at least 2." }
}, },
"additionalProperties": false "additionalProperties": false
} }
}, },
"levels": {
"about": "Spec 3.2.0. The effort levels a chat offers for a model, in order: `off` when thinking_switch is true, then the model's `efforts` — or, when it takes none, the one level `default`. What each sends (OpenAI chat): `off` — `chat_template_kwargs.enable_thinking: false` and no `reasoning_effort` anywhere; `default` — neither; an effort — `reasoning_effort` where effort_style puts it, and no `enable_thinking`. The Anthropic dialect: `off` sends no `thinking`. A level the model does not offer (an `off` for a model without the switch, from an older client or a stored chat) is read as the start level (`effort`'s rule). How it is shown: one level — no effort control at all; two, one of them `off` — a Thinking on/off switch, on being the other level; more — a slider or a list, in this order. conformance/levels.json.",
"control": ["none", "switch", "slider"]
},
"required": ["id"], "required": ["id"],
"additionalProperties": true "additionalProperties": true
} }
+15 -1
View File
@@ -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",
@@ -22,6 +26,10 @@
"job_stop": "allow", "job_stop": "allow",
"job_send": "allow", "job_send": "allow",
"report": "allow", "report": "allow",
"team_add": "allow",
"team_send": "allow",
"team_read": "allow",
"team_stop": "allow",
"web_search": "allow", "web_search": "allow",
"memory": "allow", "memory": "allow",
"session_search": "allow", "session_search": "allow",
@@ -74,6 +82,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"
} }
} }
+3 -1
View File
@@ -4,6 +4,7 @@
"order_about": "Loosest first. A project or a subagent may make the mode stricter, never looser; switching to a looser mode is always asked.", "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)."
+1 -1
View File
@@ -2,7 +2,7 @@
kept by the agent, and how git is used there is the user's answer, asked once and written down. --> kept by the agent, and how git is used there is the user's answer, asked once and written down. -->
## AGENTS.md ## AGENTS.md
This project has no AGENTS.md yet. It is the file every later session here starts from, so making it is part of the work: This project has no AGENTS.md yet. It is the file every later session here starts from, so making it is part of the work:
- Make it at the first natural pause in real work, once you know what the project is — not before you have done anything, and not in plan mode. It goes at the project root: {{root}}/AGENTS.md. - Make it at the first natural pause in real work, once you know what the project is — not before you have done anything, and not in plan mode. It goes at the project root: `AGENTS.md`.
- Before you write it, ask the user how git is to be used here, with ask_user, in one call: {{git_question}} Mark the options you would recommend. - Before you write it, ask the user how git is to be used here, with ask_user, in one call: {{git_question}} Mark the options you would recommend.
- Write their answer under a "## Git" heading. Above it, say briefly what the project is, how to build, run and test it, and the conventions that matter. - Write their answer under a "## Git" heading. Above it, say briefly what the project is, how to build, run and test it, and the conventions that matter.
- If the user does not want the file, do not bring it up again this session. - If the user does not want the file, do not bring it up again this session.
+9
View File
@@ -0,0 +1,9 @@
<!-- LLeMbas CLI and LLeMbas, spec 3.0.0: how tool results are kept short (context.md). -->
# Tool results
- Every tool result starts with its id in brackets, like `[#0012]`. A read's bracket also says the path, the lines shown, the file's length and a short hash of its content: `[#0012 src/app.ts lines 1-200 of 340 sha 1c9e04ab]`.
- Reading a file that has not changed since an earlier read in this conversation returns a stub instead of the lines: `Unchanged since #0012, which already shows lines 1-200.` The content is in that earlier result. Use it; do not read the file again to see it.
- A listing or search repeated with the same result returns `Same result as #0026.` in the same way.
- Long output is shortened to its beginning and its end, with `[1234 lines elided, full output #0020]` between them.
- `recall` with an id shows that stored result in full, or from a line on: for lines left out of a shortened output, or for a result from before the conversation was summarised. Nothing is lost; recall only what you need.
- An edit answers with the diff of the change, not the whole file. The earlier read with that diff applied is the file now: read it again only for lines you have not seen.
- After the conversation is summarised, earlier results are no longer in front of you. The summary ends with a table of the files read before it, with their ids; a file read again then comes back in full.
+12
View File
@@ -0,0 +1,12 @@
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): how to lead a team, in the system prompt whenever
the team tools are offered, and in a member's that may lead. Constant: the size the person
allows comes in a session note, never here. -->
# Leading a team
You can add members to a team with team_add: agents that work in the background, each on the part of the work it owns, while you lead. A small job you may do yourself. Once you have a member you manage and no longer do the work: tools that change things are refused to you, and reading stays yours, for review.
- Plan first: what the goal is, what the parts are, and what each one needs from the others. Split by area of ownership, not by step, so that one member owns a thing from start to finish.
- Name each member after what it owns. Brief it so that it can work without you: what to do, what done means, what to leave alone, and what to report.
- Keep the team flat while it is small — up to six or eight members, each reporting to you. Beyond that, add a lead for each area (`lead: true`), give it the area's goal, and let it build and run its own members; you then lead the leads.
- Keep the team within the size the person allows, which a session note tells you. Stop a member whose part is done or no longer needed, to make room.
- Coordinate through the room: team_send to the member who owns a thing, `all` only for what everyone needs. team_read shows the team and the room when you need to look; nothing from it reaches you unasked except your members' reports and messages addressed to you.
- Step in when a member is stuck, when two disagree, or when the plan has to change: decide, and tell those it concerns.
- Review every report against what you asked for — read the work itself where it matters — and send back what is not done. Then give your own answer: to the person, or, if you lead an area, as your report to your manager.
+14
View File
@@ -0,0 +1,14 @@
<!-- LLeMbas CLI and LLeMbas, spec 3.2.0 (team.md): a team member's standing, said once at the end
of its system prompt in place of blocks/subagent.md. The variables are what differs per member;
each stands on a line of its own, so no punctuation of theirs matters. -->
You are "{{name}}", a member of a team, and you report to "{{manager}}".
Your part: {{role}}
The team's goal: {{goal}}
{{instructions}}
You work for your manager on the part you own, and you cannot ask the person anything: ask your manager, by name, with team_send. Other members own other parts. Message the one who owns a thing when you need it from them, and `all` only for what everyone on the team needs; messages to you arrive as you work. team_read shows who is on the team and the room.
Your final reply is your report to "{{manager}}" — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle. A message may wake you again afterwards; your next final reply is then your next report.{{read_only}}
{{lead}}
+10
View File
@@ -0,0 +1,10 @@
<!-- LLeMbas, spec 3.0.0: the file system a chat works in (filesystem.md). -->
# Files
- You work in the user's file system, through the file tools. Relative paths start at {{start}}; absolute paths start at `/`.
- What this session can reach, and how: {{view}}. Nothing else exists here. A path below one of these is reached by its full path; a directory above one that is not itself listed cannot be listed, read or written.
- A listing ends a directory with `/`, marks a repository mounted from a forge `[mount]`, and an entry you can read but not change `[read-only]`.
- Read-only means read but not changed. Stage means changes are kept in this session and, in a repository, not committed. Commit means that, in a repository, the changes may also be committed and pushed to the forge.
- {{repositories}}
- Deleting moves a file to the trash, from which the user can restore it. Still, delete only what you were asked to, or what you made yourself.
- {{show}}
- {{python}}
+47 -13
View File
@@ -11,7 +11,7 @@
"intro": "the opening line: what the model is here to help with on this surface (the CLI: software engineering and running the project; LLeMbas: whatever the person is working on)", "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,44 @@
"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"
}
},
"blocks/team_lead.md": {
"scope": "shared",
"llembas": [],
"llembas_text": "identical",
"why": "spec 3.2.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment"
},
"blocks/team_member.md": {
"scope": "shared",
"llembas": [],
"llembas_text": "identical",
"why": "spec 3.2.0: LLeMbas reads it from the vendored spec (harness_spec.prompt), not from a fragment",
"variables": {
"name": "the member's name in the team",
"role": "its role as team_add gave it: one line, what it owns",
"manager": "the name of the agent that added it (`lead` for the team's lead)",
"goal": "the team's goal as it was when the member was added: the `goal` its team_add gave, else its manager's (team.md §4)",
"instructions": "its agent's own instructions (prompts/agents/<name>.md for a built-in one), as in blocks/subagent.md",
"read_only": "in plan mode: the sentence saying it can read and search but change nothing (as in blocks/subagent.md); otherwise empty",
"lead": "for a member added with `lead: true`: the whole of blocks/team_lead.md; otherwise empty"
}
} }
} }
} }
-2
View File
@@ -23,6 +23,4 @@ Leave out pleasantries, retracted ideas and anything already superseded. Do not
{{previous_summary}} {{previous_summary}}
## Transcript
{{transcript}} {{transcript}}
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- Source: OpenCode command/template/initialize.txt (MIT, © 2025 opencode), shortened, and made to <!-- Source: OpenCode command/template/initialize.txt (MIT, © 2025 opencode), shortened, and made to
ask about git the way blocks/agents-missing.md does (LLeMbas CLI's /init). --> ask about git the way blocks/agents-missing.md does (LLeMbas CLI's /init). -->
Create or update `AGENTS.md` at the project root, {{root}}/AGENTS.md: the file every later session here starts from. Every line should answer "would an agent likely get this wrong without being told?" — if not, leave it out. Create or update `AGENTS.md` at the project root: the file every later session here starts from. Every line should answer "would an agent likely get this wrong without being told?" — if not, leave it out.
{{focus}} {{focus}}
+3 -2
View File
@@ -2,16 +2,17 @@
"about": "What OpenAI-compatible servers really do, and how both harnesses cope. Each quirk is detected, handled, and — where marked — learned, so the next request does not trip it again. The CLI keeps what it learned in ~/.local/state/lembas/learned.json; LLeMbas on the model row or in process memory.", "about": "What OpenAI-compatible servers really do, and how both harnesses cope. Each quirk is detected, handled, and — where marked — learned, so the next request does not trip it again. The CLI keeps what it learned in ~/.local/state/lembas/learned.json; LLeMbas on the model row or in process memory.",
"quirks": [ "quirks": [
{ "id": "stream-options-refused", "servers": "older llama.cpp, some proxies", "symptom": "400 or 422 to a request carrying stream_options", "handling": "retry once without it, before anything was streamed", "learned": true }, { "id": "stream-options-refused", "servers": "older llama.cpp, some proxies", "symptom": "400 or 422 to a request carrying stream_options", "handling": "retry once without it, before anything was streamed", "learned": true },
{ "id": "effort-refused", "servers": "llama.cpp chat templates (Qwen3.8 refuses high)", "symptom": "an error whose text names the effort and says unexpected or supported (conformance/effort.json)", "handling": "retry once without an effort, before anything was streamed; narrow the model's efforts to those the message advertises", "learned": true }, { "id": "effort-refused", "servers": "llama.cpp chat templates (Qwen3.8 refuses high)", "symptom": "an error whose text names the effort and says unexpected or supported (conformance/effort.json)", "handling": "retry once without an effort (and without enable_thinking), before anything was streamed; narrow the model's efforts to those the message advertises. A narrowed list keeps the level off when the model has thinking_switch (models/schema.json levels)", "learned": true },
{ "id": "effort-placement", "servers": "llama.cpp", "symptom": "a top-level reasoning_effort is silently dropped", "handling": "send it top-level and as chat_template_kwargs.reasoning_effort (effort_style both)", "learned": false }, { "id": "effort-placement", "servers": "llama.cpp", "symptom": "a top-level reasoning_effort is silently dropped", "handling": "send it top-level and as chat_template_kwargs.reasoning_effort (effort_style both)", "learned": false },
{ "id": "prompt-progress", "servers": "llama.cpp with return_progress", "symptom": "prompt_progress {total, cache, processed, time_ms} frames before the reply", "handling": "ask for it, show how far the prompt has been read; a 400 to the field is learned and it is not asked again", "learned": true }, { "id": "prompt-progress", "servers": "llama.cpp with return_progress", "symptom": "prompt_progress {total, cache, processed, time_ms} frames before the reply", "handling": "ask for it, show how far the prompt has been read; a 400 to the field is learned and it is not asked again", "learned": true },
{ "id": "reasoning-fields", "servers": "llama.cpp, vLLM, DeepSeek, OpenRouter", "symptom": "reasoning arrives as reasoning_content, reasoning, or inline <think>/<thinking>/<reasoning> tags (conformance/think.json)", "handling": "all three read as reasoning; reasoning is never sent back as context", "learned": false }, { "id": "reasoning-fields", "servers": "llama.cpp, vLLM, DeepSeek, OpenRouter", "symptom": "reasoning arrives as reasoning_content, reasoning, or inline <think>/<thinking>/<reasoning> tags (conformance/think.json)", "handling": "all three read as reasoning. From spec 3.2.0 an assistant message of the current turn carries its reasoning back as reasoning_content, and earlier turns' carry none (context.md §8); inline tags are never sent back as text", "learned": false },
{ "id": "error-in-200-stream", "servers": "llama.cpp", "symptom": "an error object inside a 200 event stream, sometimes as an unterminated last line", "handling": "read as the error it is, not as an empty reply", "learned": false }, { "id": "error-in-200-stream", "servers": "llama.cpp", "symptom": "an error object inside a 200 event stream, sometimes as an unterminated last line", "handling": "read as the error it is, not as an empty reply", "learned": false },
{ "id": "tool-call-without-index", "servers": "some llama.cpp builds", "symptom": "tool-call fragments with no index", "handling": "fragments without an index continue the last call", "learned": false }, { "id": "tool-call-without-index", "servers": "some llama.cpp builds", "symptom": "tool-call fragments with no index", "handling": "fragments without an index continue the last call", "learned": false },
{ "id": "swap-banner", "servers": "llama-swap", "symptom": "a loading banner streamed before the reply while the model loads", "handling": "not shown as the reply", "learned": false }, { "id": "swap-banner", "servers": "llama-swap", "symptom": "a loading banner streamed before the reply while the model loads", "handling": "not shown as the reply", "learned": false },
{ "id": "model-unloaded", "servers": "llama-swap", "symptom": "a 'model unloaded' frame mid-reply, when another client loaded a different model", "handling": "treated as a server error: retried once", "learned": false }, { "id": "model-unloaded", "servers": "llama-swap", "symptom": "a 'model unloaded' frame mid-reply, when another client loaded a different model", "handling": "treated as a server error: retried once", "learned": false },
{ "id": "zero-usage", "servers": "several", "symptom": "a usage block of all zeros", "handling": "ignored; the count is estimated (marked ~) instead", "learned": false }, { "id": "zero-usage", "servers": "several", "symptom": "a usage block of all zeros", "handling": "ignored; the count is estimated (marked ~) instead", "learned": false },
{ "id": "no-context-length", "servers": "llama-swap /v1/models, most OpenAI-compatible servers", "symptom": "/models lists ids without a context length", "handling": "ask llama-server's /props (through llama-swap's /upstream/<model>), Ollama's /api/show, else unknown — never assume one", "learned": true }, { "id": "no-context-length", "servers": "llama-swap /v1/models, most OpenAI-compatible servers", "symptom": "/models lists ids without a context length", "handling": "ask llama-server's /props (through llama-swap's /upstream/<model>), Ollama's /api/show, else unknown — never assume one", "learned": true },
{ "id": "thinking-default", "servers": "llama.cpp, vLLM and SGLang serving Qwen3.x and Nemotron 3.x", "symptom": "a request that sends no effort still thinks, at the effort the server was started with — no effort is not no thinking", "handling": "thinking is switched off only by chat_template_kwargs.enable_thinking: false, on a model marked thinking_switch (models/schema.json levels); a request's own reasoning_effort overrides the server's default, and a value the template does not know is effort-refused", "learned": false },
{ "id": "max-completion-tokens", "servers": "OpenAI reasoning models", "symptom": "max_tokens refused", "handling": "send max_completion_tokens where configured", "learned": false } { "id": "max-completion-tokens", "servers": "OpenAI reasoning models", "symptom": "max_tokens refused", "handling": "send max_completion_tokens where configured", "learned": false }
] ]
} }
+235
View File
@@ -0,0 +1,235 @@
# Teams
Spec 3.2.0. **Subagents that work together.** A session's agent — the **lead** — may add
**members**, background agents that each own a part of the work, and lead them as a manager: it
plans, splits the work, coordinates and reviews, and the members do it. Members talk to each other
and to their managers in the team's **room**. A member may itself lead an area with members of its
own, so a team grows from a few agents side by side into a small company with one goal. The same in
a chat on the server and in an agent session on a device, in both projects.
The tools are `tools/team_add.json`, `team_send.json`, `team_read.json`, `team_stop.json`; the texts
and numbers `loop.json` `team` (and the size notes in `context.note`); the prompt texts
`prompts/blocks/team_lead.md` and `team_member.md`; the cases `conformance/team.json`.
## 1. Words
- **Agent**: the lead or a member. Every agent has a **name**: the lead is `lead`; a member the name
it was added under. Names are unique in the team.
- **Member**: an agent added with `team_add`, by the lead or by a member that leads. Its **manager**
is the agent that added it. The members an agent added, and theirs, down the tree, are **under**
it; with the agent itself they are its **subtree**.
- **Manager**: an agent with at least one member, and the lead from the start when the team's `min`
is 2 or more (§2). A manager does not do the work (§6).
- **Team**: the lead and every member, one per lead session; **its size** is how many agents it has
now (§3).
- **Room**: the team's messages, numbered from 1 in the order they were posted, each with `n`, `from`
(a name), `to` (as addressed: names, or `all`), its recipients (resolved when it was posted), the
text, the time, and whether it is a report.
## 2. The size setting, and when a team is offered
A chat has the setting **`agents: {min, max}`** — the agents the team may have, the lead included.
- `1 ≤ min ≤ max ≤ cap`. `cap` is the instance's (LLeMbas) or the global configuration's (the CLI),
default `loop.json` `team.agents.cap` (32); a project's configuration never raises it. A value
outside is refused where it is set, with what the bounds are, never clamped silently; a stored
setting the cap was lowered under is read as `max = min(max, cap)`, `min = min(min, max)`.
- Defaults for a new chat: `min` 1, `max` 4 (`team.agents`) — the lead and up to three members. LLeMbas
keeps a per-person default for new chats, seeded with those; the CLI reads them from its
configuration.
- The setting is kept with the chat. A change during a session is told to the lead as a note on its
next user message (`context.md` §10; `context.note.team`, below), never by changing the system
prompt.
**A team is offered** — the setting shown, the team tools listed, `blocks/team_lead.md` in the
system prompt — when both hold, and only then:
1. Subagents are enabled for the session: LLeMbas — the instance allows them and the chat's model
has its subagent tool; the CLI — its subagents are not switched off in the configuration.
2. **The chat's model serves at least two requests at once**: `capacity.parallel ≥ 2`, or neither
`parallel` nor `single_session` is set (a hosted API; `models/schema.json`).
Both are fixed for as long as the system prompt is (`context.md` §10, rule 4: a model switch makes
a new prompt and a new list). Otherwise the setting is not shown and none of the four tools is
listed, and the session's `task` works as before.
While the tools are listed they need **`team`** (`requires`): met when `max ≥ 2` and the model still
serves two at once — or, for the lead, when its team still has a member (so a lead whose person
lowered `max` to 1 can still talk to, read and stop the members it has). A call while it is not
met answers, word for word, `team.unavailable.one_request` when the model's capacity no longer
allows a team (its metadata changed after the prompt was made), else `team.unavailable.one_agent`
— which is also the tools' own `unavailable` text.
The meaning of the setting:
- **`max = 1`**: one agent, no team, and no helper of any kind: `task` answers `one_agent` too
(while the lead has no member; with members left from a larger setting, `full`).
- **`min ≥ 2`**: the lead works through a team from the start — it is a manager before its first
member (§6), and the note asks for at least `min − 1` members.
- **`min = 1`, `max ≥ 2`**: the lead decides; a small job it may do alone. Once it has a member it is
a manager.
## 3. Size, and what counts
**The size** of a team is: the lead, every member (whatever its state — a finished member can be
woken by a message, so it keeps its place), and every `task` helper running in the team, started by
any agent in it (the CLI's background `task` jobs and foreground ones alike, while they run).
A stopped member does not count: stopping is how room is made.
- **`team_add` is refused past `max`** — when the size would become more than `max`, with
`team.texts.full`. So is `task`, while a team is offered: with `full`, or with `one_agent` when
`max` is 1 and the lead has no member. A `job_send` that would start a finished background
`task` helper again is refused the same way.
- **Lowering `max` stops nobody.** Members already there stay; nothing new is added until the size
is under the new `max`.
- The size is across **the whole tree**: a member that leads adds within the same `max` as the lead.
## 4. Members
- `team_add` starts the member **at once, in the background**, under the caller: the existing job
machinery (a hidden chat on LLeMbas with its `chat_jobs` row, a subagent job in the CLI), running
unattended — no `ask_user`, the chat's own permission ceiling, its mode no looser than its
manager's (`permission/modes.json`).
- **Its agent** is `general` (the default: it does the work) or `explore` (read-only); `model` is
as `task`'s — a model the person may use, the default being the caller's.
- **Its tools** are those its agent gives a subagent, plus `team_send` and `team_read`; one added
with `lead: true` also has `team_add` and `team_stop`. They are fixed when it is made (§10 rule 4:
a subagent's narrowing). `report` (`offered: subagent`) sends its manager a message before its
final reply: posted in the room to the manager, delivered with the message mark.
- **Its system prompt** is built once, when it is added: what a subagent of that agent gets, with
`blocks/team_member.md` in place of `blocks/subagent.md` — its name, role, manager, the team's
goal, the agent's own instructions, and for one with `lead: true` the whole of
`blocks/team_lead.md`. Its first user message is its brief (`task`), as given.
- **The team's goal** is what a member is told the team is for: the `goal` the caller gave in
`team_add`, else the caller's own goal. The lead's own goal is the last `goal` it gave; **the
lead's first `team_add` must give one** (`team.texts.no_goal`). A member already working keeps the
goal it was told; a new goal reaches it only as a message.
- **`team_add`'s refusals are checked in this order**, the first that applies answering:
`unavailable` (§2); `all_name` (the name `all`); `bad_name` (not `team.names.pattern`); `taken`
(the name of an agent on the team, `lead` included); `no_goal`; `full`. Then the member is added:
`added`, or `added_waiting` when it has to wait for a slot (§8). An unknown `agent` or `model`
answers as `task` does.
- **Members are not jobs to the model.** `job_list` does not show them and the `job_*` tools do not
take them; the team tools do. (An implementation may run them on its job machinery.)
- **States**: `working` (a turn is running), `waiting` (it has work, and waits for a slot, §8),
`finished` (its last turn ended; it can be woken). The lead is `working`, `waiting` or `idle`.
- A member's background commands stop when its turn ends (`loop.json` `jobs.lifecycle`). Deleting
the lead's session stops the whole team.
## 5. The room: who receives what
- **`team_send`** posts one message: `to` a list of names, or `["all"]` (a lone string is read as a
one-name list). Names are deduplicated, kept in the order given. `all` anywhere in the list means
all, and the other names in it are ignored.
- **Recipients**: for named agents, exactly those named; for `all`, every agent of the team but the
sender — the lead and every member, whoever manages them (the room is one channel for the whole
company). Resolved when the message is posted: an agent added later does not get it.
- **Refused, and nothing posted**, checked in this order: `all` when nobody else is on the team
(`nobody`); a name that is not on the team (`unknown`, the first such name in the order given);
the sender's own name among them (`self`). The answer when
posted: `team.texts.sent`, `to` as addressed.
- **What an agent receives** is only what is addressed to it, by name or as `all`, and — a manager
— **the reports of its own members**. A manager is not sent its members' messages to each other;
it reads them with `team_read`, on demand, so nothing floods its context.
- **A report**: a member's final reply, when its turn ends, is posted to its manager (`report`
true) and delivered to it. An empty reply reads `team.texts.no_report`; a turn that ended
otherwise than by the model stopping adds `team.texts.ended` (`steps`, `budget`, `error — <text>`),
after a blank line. A sub-manager's final reply is likewise its report to its manager.
- **The person's** messages go to the lead as its user messages, not into the room; the lead's
answers to the person are not room messages either. The person sees the whole room and the tree
in the UI (LLeMbas: the Team panel; the CLI: `/team`), and can stop a member there — said to the
lead as a note (`context.note.team_stopped`).
- **`team_read`** answers the tree — every agent, the whole team, in tree order: name, role, its
manager, state, model, last activity — and the room's messages numbered above `since` (default 0)
that the caller may see, oldest first, at most `team.read_max` (50), and how to read on. **Who may
see a message**: the lead every one; any other agent those whose sender or one of whose recipients
is in its subtree — for a member that leads nobody, what it sent and what it received.
## 6. A manager does not do the work
A manager's **work** calls are refused, before the permission check — nothing is asked — with
`team.texts.manager`, word for word:
> You lead a team: give this to a member (team_add or team_send).
**Work** is a call whose risk class (`risk`; an MCP tool's: `read` when it says `readOnlyHint`, else
`execute`) is `write` or `execute`, with two exceptions that stay a manager's:
- `job_stop` — managing what runs is not work;
- a `bash` call **the default rules alone allow in manual mode** (`permission/defaults.json`, the
`permission` conformance area's evaluation with no rules of the person's): reading commands —
`git status`, `git diff`, `cat`, `ls`, `grep` — for review.
So with the spec's tools: **refused** — `write`, `edit`, `multiedit`, `apply_patch`, `mkdir`, `mv`,
`rm`, `skill_manage` (write); `bash` beyond reading, `python`, `commit`, `branch`, `pr`, `merge`,
`tag`, `release` (execute); an MCP tool that does not say it only reads. **Kept** — every `read`
tool (`read`, `glob`, `grep`, `list`, `search`, `recall`, `web_search`, `web_fetch`, …), every
`interact` tool (`ask_user` for the lead, `plan_*`, `task`, `memory`, `show`, `job_send`, `report`,
the team tools, …) and `job_stop`. `team.work` lists the same, and a new tool is placed by its risk
class.
**The tool list does not change** when an agent becomes a manager (`context.md` §10, rule 4): the
same tools are listed and the refused ones answer the refusal. An agent is a manager while it has
a member — a finished one included; one whose members were all stopped is not, unless it is the
lead and `min ≥ 2` with the team offered. `task` stays: a one-off helper is delegation, and counts
toward the size (§3).
## 7. Delivery, and waking
A message or a report reaches its recipient **as a marked message** — the jobs mechanism
(`loop.json` `jobs`): while the recipient's turn runs, as a user message at its next step boundary
(which does not start a turn: `context.md` §8); otherwise it **wakes** the recipient — a finished
member, or the idle lead — as a new turn of its own, marked `machine`, with its context intact, as
`job_send` restarts a finished helper. A woken agent needs a slot (§8). Several waiting at one step
boundary go in one user message, in room order, separated by a blank line.
Every one begins with its mark, on a line of its own, then the text:
```
[Team message from parser to lead:]
The grammar is done; the tests are next.
```
- message: `[Team message from {{from}} to {{to}}:]` — `to` as addressed: the names joined with
`, `, or `all`; the same for every recipient.
- report: `[Team report from {{from}} to {{to}}:]` — `to` the manager.
A session that is not open keeps them until it is resumed, as a job's note.
## 8. Capacity: a member waits for a slot
A model serves `parallel` requests at once (`models/schema.json`). **An agent holds a slot of its
model while its turn runs** — the lead, a member, a `task` helper. An agent with work (added, woken,
given a message while finished, the lead given the person's message) starts when its model has a
free slot and none of its `capacity.group`'s other models is busy; otherwise it is `waiting`.
`team_add` is never refused for capacity (only for size): its answer says the member waits.
Waiting agents start **in order**: the lead first (the person waits on it), then the others in the
order they began waiting. A slot frees when a turn ends or an agent is stopped. `task` keeps its own
rule (`loop.json` `capacity`: refused where its model is full), and its helper holds a slot.
## 9. Stopping
`team_stop` stops one member **and its whole subtree**, or with `all` every member under the caller:
their turns stop where they are, their background commands with them, messages waiting for them
are dropped, and they **leave the team** — the size drops, their slots free, their names may be
used again. No report comes back from a stopped member. Refused, in this order: `all` with nobody
under the caller (`nobody_to_stop`); a name not on the team (`unknown`); the caller itself or an
agent not under it (`not_yours`). The answer, `stopped`, names the stopped in tree order.
## 10. What the lead is told
- `blocks/team_lead.md` — constant, no numbers — is in the system prompt whenever the team tools
are listed: how to lead.
- The size is in a **note** (`context.md` §10, `context.note`): on the first user message of the
session, and on the next one after the setting changed — `team` (`min`, `max`), with `team_lead`
after it when `min ≥ 2` (`members` = `min − 1`); when `max` is 1, `team_one` instead. One block.
A member the person stopped is its own block, `team_stopped`, with the names. Members are told
nothing of the size: `team_add`'s answer says how big the team is.
## 11. Over the link
An agent chat's team runs on the device, as its jobs do. Spec 3.2.0 does not carry the room over
ACP yet: a device with the `jobs` capability reports each member as a subagent job (`job` events,
`description` being `<name>: <role>`), so the client's job strip shows them, and the room is shown
on the device (`/team`). Carrying the room is a later spec's.
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+39
View File
@@ -0,0 +1,39 @@
{
"name": "branch",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"unavailable": "No repository from a forge is open to this session, so there is nothing to branch here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Create a branch in a repository (a mount), from `from` (default: the branch this session works on), and switch this session's working copy of it to the new branch. Staged changes come along.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"name": {
"type": "string",
"description": "The new branch's name"
},
"from": {
"type": "string",
"description": "Branch, tag or commit to start from"
}
},
"required": [
"path",
"name"
]
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"name": "commit",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"unavailable": "No repository from a forge is open to this session, so there is nothing to commit here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Send the staged changes of a repository (a mount) to the forge as one commit on the branch this session works on, under the user's account. Every change this session made in that repository since its last commit goes in, unless `paths` names some of them.\n\n- Refused when the branch has moved on the forge since this session's working copy was based on it: the answer shows what changed there. Look at it, then commit again.\n- Write the message as the repository's history does: a short subject line, then why, where it is not obvious.\n- Needs commit access to the repository in this session. With stage access the changes stay staged, for the user to commit.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"message": {
"type": "string",
"description": "The commit message"
},
"paths": {
"type": "array",
"items": {
"type": "string"
},
"description": "Only these changed paths (default: every staged change)"
}
},
"required": [
"path",
"message"
]
}
}
+1
View File
@@ -1,6 +1,7 @@
{ {
"name": "decisions", "name": "decisions",
"scope": "cli", "scope": "cli",
"sessions": "agent",
"risk": "interact", "risk": "interact",
"exclusive": false, "exclusive": false,
"block": "tool", "block": "tool",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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
View File
@@ -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",
+46
View File
@@ -0,0 +1,46 @@
{
"name": "merge",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": true,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"confirm": "always",
"unavailable": "No repository from a forge is open to this session, so there is nothing to merge here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Merge a pull request on the forge. The user is always asked first, in every mode, and shown the pull request: nothing is merged without their yes.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"number": {
"type": "integer",
"minimum": 1,
"description": "The pull request's number"
},
"method": {
"type": "string",
"enum": [
"merge",
"squash",
"rebase"
],
"description": "How to merge (default: the repository's setting)"
}
},
"required": [
"path",
"number"
]
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "mkdir",
"scope": "llembas",
"sessions": "chat",
"risk": "write",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Make a directory, and any missing directories above it. Making one that already exists is not an error.\n\n- `path` may be absolute or relative to the working directory.\n- In a repository (a mount), an empty directory stays in this session's working copy; a commit leaves empty directories out.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory to make"
}
},
"required": [
"path"
]
}
}
+1
View File
@@ -1,6 +1,7 @@
{ {
"name": "multiedit", "name": "multiedit",
"scope": "cli", "scope": "cli",
"sessions": "agent",
"risk": "write", "risk": "write",
"exclusive": false, "exclusive": false,
"block": "edit", "block": "edit",
+31
View File
@@ -0,0 +1,31 @@
{
"name": "mv",
"scope": "llembas",
"sessions": "chat",
"risk": "write",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Move or rename a file or directory. A directory moves with everything in it.\n\n- `from` and `to` may be absolute or relative to the working directory; `to` must not exist yet.\n- Inside one repository (a mount) the move is staged like any other change. Between a repository and the rest of the file system, or between two repositories, it is refused: read the file and write it where it should go instead.",
"parameters": {
"type": "object",
"properties": {
"from": {
"type": "string",
"description": "What to move"
},
"to": {
"type": "string",
"description": "The new path, including the name"
}
},
"required": [
"from",
"to"
]
}
}
+2 -8
View File
@@ -1,6 +1,7 @@
{ {
"name": "note_manage", "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"
]
}
} }
} }
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+46
View File
@@ -0,0 +1,46 @@
{
"name": "pr",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"unavailable": "No repository from a forge is open to this session, so there is nothing to pr here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Open a pull request on the forge, from `head` (default: the branch this session works on) into `base` (default: the repository's default branch). Only committed changes are in it: commit first.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"title": {
"type": "string"
},
"body": {
"type": "string",
"description": "What it changes and why"
},
"head": {
"type": "string",
"description": "The branch with the changes"
},
"base": {
"type": "string",
"description": "The branch to merge into"
}
},
"required": [
"path",
"title"
]
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"name": "python",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": false,
"block": "shell",
"purpose": "purpose",
"requires": [
"page"
],
"unavailable": "Python cannot run now: no open browser page is attached to this chat.",
"aliases": {
"cli": [],
"llembas": []
},
"variables": {
"packages": "the packages the instance provides beyond the standard library, as a list, or \"none\" — implementation-defined; LLeMbas 2.0: none (the standard library only)",
"seconds": "the time limit of one call, in seconds — implementation-defined; LLeMbas 2.0: 30",
"memory": "the memory limit of one call, with its unit — implementation-defined; LLeMbas 2.0: 512 MB",
"output": "how much printed output comes back, with its unit — implementation-defined; LLeMbas 2.0: 20,000 characters"
},
"description": "Run Python for calculations and for working with files — parse JSON or CSV, transform text, compute. It runs in the user's browser (Pyodide), sandboxed: no network, no shell, no packages beyond the standard library and these: {{packages}}. Returns what the code printed and the value of its last expression.\n\n- `files` are copied in before the run, at the same paths, from what this session can read.\n- Files the code writes come back only when listed in `save`: each is written as the write tool would write it, with this session's permissions.\n- At most {{seconds}} seconds and {{memory}} of memory per call; printed output past {{output}} is cut.\n- Every call starts fresh: nothing is kept between calls.",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The Python code to run"
},
"files": {
"type": "array",
"items": {
"type": "string"
},
"description": "Files to copy in before the run"
},
"save": {
"type": "array",
"items": {
"type": "string"
},
"description": "Files the code writes that are to be saved back"
}
},
"required": [
"code"
]
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "read", "name": "read",
"scope": "cli", "scope": "shared",
"sessions": "both",
"risk": "read", "risk": "read",
"exclusive": false, "exclusive": false,
"block": "read", "block": "read",
+36
View File
@@ -0,0 +1,36 @@
{
"name": "recall",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "read",
"purpose": false,
"aliases": {
"cli": [],
"llembas": []
},
"description": "Show a stored tool result again, in full, by its id (`#a3f2`): the lines a shortened output left out, or a result from before the conversation was summarised. Every result of this session is kept, so nothing is lost.\n\n- Returns up to 2000 lines from `offset` (1-indexed); call again with a larger offset for more.\n- An unchanged file is not worth recalling when its earlier result is still above: that result is the content.",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The result's id, e.g. #a3f2"
},
"offset": {
"type": "integer",
"minimum": 1,
"description": "First line to return, 1-indexed"
},
"limit": {
"type": "integer",
"minimum": 1,
"description": "How many lines (default 2000)"
}
},
"required": [
"id"
]
}
}
+51
View File
@@ -0,0 +1,51 @@
{
"name": "release",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": true,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"confirm": "always",
"unavailable": "No repository from a forge is open to this session, so there is nothing to release here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Publish a release on the forge from a tag that already exists, with its notes. Make the tag first (tag): a release is never made for a tag that does not exist. The user is always asked first, in every mode.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"tag": {
"type": "string",
"description": "The existing tag"
},
"title": {
"type": "string"
},
"notes": {
"type": "string",
"description": "The release notes (usually the tag's message)"
},
"prerelease": {
"type": "boolean"
},
"draft": {
"type": "boolean"
}
},
"required": [
"path",
"tag",
"title",
"notes"
]
}
}
+1
View File
@@ -1,6 +1,7 @@
{ {
"name": "report", "name": "report",
"scope": "shared", "scope": "shared",
"sessions": "both",
"risk": "interact", "risk": "interact",
"exclusive": false, "exclusive": false,
"block": "tool", "block": "tool",
+26
View File
@@ -0,0 +1,26 @@
{
"name": "rm",
"scope": "llembas",
"sessions": "chat",
"risk": "write",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Delete a file or directory by moving it to the trash, from which the user can restore it. A directory goes with everything in it: list it first.\n\n- `path` may be absolute or relative to the working directory.\n- In a repository (a mount) the deletion is staged in this session's working copy and reaches the repository only with a commit.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File or directory to delete"
}
},
"required": [
"path"
]
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"name": "search",
"scope": "llembas",
"sessions": "chat",
"risk": "read",
"exclusive": false,
"block": "search",
"purpose": "purpose",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Search the files this session can see, by meaning and by words together: the best passages first, each with its path and line range. Use it to find where something is discussed or answered when you do not know the exact words; use grep for exact text or a pattern, glob for file names.\n\n- `path` limits the search to a directory (default: everything this session can see).\n- A passage is an extract. Read the file around it before answering from it when the answer turns on detail, and cite the path.\n- Only indexed files are searched: text, Markdown, code, JSON and the text of PDF and DOCX files, up to a size limit. A file just written is found once it has been indexed.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What to look for, in words: a question or the topic"
},
"path": {
"type": "string",
"description": "Directory to search (default: everything this session can see)"
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"description": "How many passages (default 10)"
}
},
"required": [
"query"
]
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "session_search", "name": "session_search",
"scope": "cli", "scope": "shared",
"sessions": "both",
"risk": "read", "risk": "read",
"exclusive": false, "exclusive": false,
"block": "search", "block": "search",
+1
View File
@@ -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",
+35
View File
@@ -0,0 +1,35 @@
{
"name": "show",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
"purpose": "purpose",
"requires": [
"display"
],
"unavailable": "Nothing can be shown now: this session has no screen to show a file on. Give the user the path instead.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Open a file in the user's viewer, beside the conversation, without them having to look for it: Markdown rendered, code highlighted, plain text as it is, and HTML as a page in a sandboxed frame that cannot reach this app, the user's data or anything the instance does not allow. Use it when the user should see the file itself — a page or a chart you made, a document you wrote — not for files you only read.\n\n- `path` may be absolute or relative to the working directory. The file must exist and be readable in this session: write it first.\n- `line` scrolls a text file to that line.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File to show"
},
"line": {
"type": "integer",
"minimum": 1,
"description": "Line to scroll to (text files)"
}
},
"required": [
"path"
]
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "skill_manage", "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
View File
@@ -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
View File
@@ -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",
+45
View File
@@ -0,0 +1,45 @@
{
"name": "tag",
"scope": "llembas",
"sessions": "chat",
"risk": "execute",
"exclusive": true,
"block": "tool",
"purpose": "purpose",
"requires": [
"forge"
],
"confirm": "always",
"unavailable": "No repository from a forge is open to this session, so there is nothing to tag here.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Create an annotated tag on the forge, at `at` (default: the newest commit of the branch this session works on), its message being the release note. The user is always asked first, in every mode.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Any path inside the repository (the mount) this is about"
},
"name": {
"type": "string",
"description": "The tag, e.g. v1.2.0"
},
"message": {
"type": "string",
"description": "The tag's message"
},
"at": {
"type": "string",
"description": "Branch or commit to tag"
}
},
"required": [
"path",
"name",
"message"
]
}
}
+1
View File
@@ -1,6 +1,7 @@
{ {
"name": "task", "name": "task",
"scope": "shared", "scope": "shared",
"sessions": "both",
"risk": "interact", "risk": "interact",
"exclusive": false, "exclusive": false,
"block": "task", "block": "task",
+1
View File
@@ -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",
+62
View File
@@ -0,0 +1,62 @@
{
"name": "team_add",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "task",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Add a member to your team and start it at once, in the background, on its part of the work. You lead: members do the work while you plan, coordinate and review — once you have a member, tools that change things are refused to you.\n\n- `name` is how everyone addresses it: lowercase letters, digits and hyphens, after what it owns (`parser`, `api-tests`), unique in the team.\n- `role` is one line: what it owns. `task` is its brief and must stand on its own — it sees nothing of this conversation: what to do, when it is done, and what to report.\n- `goal` is the team's goal in a line; your first member needs it, and every member is told it.\n- `lead: true` lets it add members of its own and lead an area; it then manages as you do.\n- The person sets how many agents the team may have; past that this is refused. When the model is already serving as many requests as it can at once, the member waits for a slot and starts when one frees.\n- Its final reply comes to you as its report. Meanwhile team_send messages it, team_read shows the team and the room, team_stop stops it.\n- Agents: {{agents}}",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{0,31}$",
"description": "Its name in the team, after what it owns"
},
"role": {
"type": "string",
"minLength": 1,
"description": "One line: what it owns"
},
"task": {
"type": "string",
"minLength": 1,
"description": "Its brief, self-contained"
},
"goal": {
"type": "string",
"description": "The team's goal in one line: needed with your first member; a new one is told to members added from now on"
},
"lead": {
"type": "boolean",
"description": "May add members of its own and lead an area (default: false)"
},
"agent": {
"type": "string",
"description": "Which agent it is (default: general)"
},
"model": {
"type": "string",
"description": "Run it on another model you may use (from the list in your instructions). Default: yours."
}
},
"required": [
"name",
"role",
"task"
]
},
"variables": {
"agents": "the agents a member may be: `name — description`, separated by \"; \" (as task's)"
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "team_read",
"scope": "shared",
"sessions": "both",
"risk": "read",
"exclusive": false,
"block": "tool",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "The team and its room: every agent (name, role, who leads it, state, model, last activity), and the room's messages after `since` that are yours to see — those you sent or received and those of the members under you; the lead sees them all. Messages are numbered: pass the last number you saw as `since` to read only what is new.",
"parameters": {
"type": "object",
"properties": {
"since": {
"type": "integer",
"minimum": 0,
"description": "Show messages after this number (default: 0, from the start)"
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "team_send",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Send a message through your team's room: to one or more agents by name, or to `all`. It reaches each at its next step, or wakes one that has finished, its context intact. Message the agent who owns a thing; use `all` only for what everyone needs. The team's lead is `lead`; your manager is reached by its name.",
"parameters": {
"type": "object",
"properties": {
"to": {
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"description": "Names, or [\"all\"]"
},
"message": {
"type": "string",
"minLength": 1
}
},
"required": [
"to",
"message"
]
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "team_stop",
"scope": "shared",
"sessions": "both",
"risk": "interact",
"exclusive": false,
"block": "tool",
"purpose": false,
"requires": [
"team"
],
"unavailable": "The person allows one agent in this chat: there is no team. Do the work yourself.",
"aliases": {
"cli": [],
"llembas": []
},
"description": "Stop a member of your team, and the members under it, where they are: their work stops and they leave the team, which makes room for others. Only members under you, directly or through your members; `all` stops every one of them. Nothing they had not reported comes back — ask for a report with team_send first when you need it.",
"parameters": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"description": "A member under you, or all"
}
},
"required": [
"name"
]
}
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -1
View File
@@ -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
View File
@@ -221,8 +221,9 @@ if [ "$starter" -eq 1 ] && [ ! -e "$CONFIG/config.yaml" ]; then
# The full list: https://llembas.eu/docs/. An editor with a YAML language server # 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
View File
@@ -1,7 +1,7 @@
{ {
"name": "lembas-cli", "name": "lembas-cli",
"version": "1.5.1", "version": "2.0.0-beta.3",
"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",
+39 -9
View File
@@ -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,11 +866,11 @@
} }
}, },
"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": {
"description": "The effort a session starts with, when the model takes it; otherwise the model's own default.", "description": "The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level.",
"anyOf": [ "anyOf": [
{ {
"type": "string", "type": "string",
@@ -889,9 +886,42 @@
{ {
"type": "string", "type": "string",
"const": "off" "const": "off"
},
{
"type": "string",
"const": "default"
} }
] ]
}, },
"agents": {
"description": "Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session.",
"type": "object",
"properties": {
"min": {
"description": "The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start.",
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"max": {
"description": "The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper.",
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"cap": {
"description": "Global config only. The highest max a session may set (default 32).",
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
}
},
"additionalProperties": false
},
"subagents": {
"description": "false: no subagents — no task tool and no team.",
"type": "boolean"
},
"settings_tool": { "settings_tool": {
"description": "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.", "description": "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.",
"type": "string", "type": "string",
@@ -999,7 +1029,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",
+22 -347
View File
@@ -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": {
@@ -132,7 +127,7 @@
"maximum": 1 "maximum": 1
}, },
"efforts": { "efforts": {
"description": "The model's own effort vocabulary, in offering order.", "description": "The model's own effort vocabulary, in offering order. [] — it takes none (its one level is `default`).",
"type": "array", "type": "array",
"items": { "items": {
"type": "string", "type": "string",
@@ -146,8 +141,12 @@
] ]
} }
}, },
"thinking_switch": {
"description": "The server turns thinking off for a request with chat_template_kwargs.enable_thinking false: only then is the level `off` offered.",
"type": "boolean"
},
"effort": { "effort": {
"description": "Default effort; `off` sends none.", "description": "The level a conversation starts with, when the model offers it: an effort, `off` (thinking switched off, with thinking_switch) or `default` (nothing sent, for a model that takes no efforts).",
"anyOf": [ "anyOf": [
{ {
"type": "string", "type": "string",
@@ -163,11 +162,15 @@
{ {
"type": "string", "type": "string",
"const": "off" "const": "off"
},
{
"type": "string",
"const": "default"
} }
] ]
}, },
"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 +178,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 +186,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 +209,18 @@
"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. The same as parallel: 1.",
"type": "boolean" "type": "boolean"
}, },
"parallel": {
"description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2.",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"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 +233,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
View File
@@ -68,12 +68,12 @@ export async function drive(opts: { cols: number; rows: number; bin?: string; ho
} }
export function fakeHome(script: Parameters<typeof fakeProvider>[0]) { 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
View File
@@ -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.
+95 -30
View File
@@ -33,7 +33,8 @@ import { addPendingDeleted, clearPendingDeleted, pendingDeleted } from "./pendin
import { shellLaunch } from "./shellrc.ts" import { shellLaunch } from "./shellrc.ts"
import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts" import { sessionTurnFile, sessionTurns, TurnLog } from "./turns.ts"
import { expandHome } from "../config/paths.ts" import { expandHome } from "../config/paths.ts"
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "../config/schema.ts" import { asMode, MODES, type Mode } from "../config/schema.ts"
import { isLevel, readLevel, type Level } from "../provider/effort.ts"
import { HARNESS_VERSION } from "../harness.ts" import { HARNESS_VERSION } from "../harness.ts"
import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts" import { findProject, trustedRoots, trustedWithin, trustOf } from "../project/root.ts"
import { VERSION } from "../version.ts" import { VERSION } from "../version.ts"
@@ -42,12 +43,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. */
@@ -130,10 +132,10 @@ interface Terminal {
/** Most terminals one link may hold at once. */ /** Most terminals one link may hold at once. */
const MAX_TERMINALS = 4 const MAX_TERMINALS = 4
/** An effort as the instance sends it: a level, or "off"; anything else is not one. */ /** An effort level as the instance sends it (an effort, "off" or "default"); anything else is not
export function effortOf(v: unknown): Effort | "off" | undefined { * one. Whether the model offers it is the session's to decide (readLevel). */
if (v === "off" || v === "" || v === null) return "off" export function effortOf(v: unknown): Level | undefined {
return typeof v === "string" && (EFFORTS as readonly string[]).includes(v) ? (v as Effort) : undefined return isLevel(v) ? v : undefined
} }
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 } const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
@@ -209,14 +211,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 +270,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 +371,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 +428,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 +490,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 +582,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 +619,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, 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,11 +664,18 @@ 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 // A level the model does not offer is read as its start level; the answer says the one used.
return { model: s.app.engine.model.ref, effort: s.app.engine.effort ?? "off" } if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
} }
private modes(): Mode[] { private modes(): Mode[] {
@@ -660,6 +711,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 +738,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 +747,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
+30 -16
View File
@@ -8,12 +8,14 @@
// account lost the permission) ends the link for good: retrying it would only knock on a closed door. // account lost the permission) ends the link for good: retrying it would only knock on a closed door.
import { existsSync, readFileSync } from "node:fs" import { existsSync, readFileSync } from "node:fs"
import { hostname } from "node:os" import { hostname } from "node:os"
import { resolve as resolvePath } from "node:path"
import { expandHome } from "../config/paths.ts"
import { remoteBlock } from "../config/remote.ts" import { remoteBlock } from "../config/remote.ts"
import { refusedLocally } from "../project/local.ts" 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"
@@ -143,10 +145,11 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
o.onStatus?.(s) o.onStatus?.(s)
return s return s
} }
const ca = instance.ca && existsSync(instance.ca) ? readFileSync(instance.ca, "utf8") : undefined // The CA the login was given, as a full path (an older login kept what was typed: relative, or
/** A 4400 to a hello that said 2: the protocols known were stale (or discovery said more than the // with ~, which a service started elsewhere does not find). One that is gone is said, not skipped.
* link takes) — the next dial, at once, says 1; only a 4400 to that stops the link. */ const caPath = instance.ca ? resolvePath(expandHome(instance.ca)) : undefined
let downgraded = false const caGone = caPath && !existsSync(caPath) ? caPath : undefined
const loginCa = caPath && !caGone ? readFileSync(caPath, "utf8") : undefined
while (!o.signal?.aborted) { while (!o.signal?.aborted) {
attempts++ attempts++
@@ -154,12 +157,28 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
// What the instance speaks now: asked at every dial, since it may have been updated // What the instance speaks now: asked at every dial, since it may have been updated
// since the login; what was said last when it cannot be asked. // since the login; what was said last when it cannot be asked.
let protocols = instances()[instance.connection]?.protocols ?? instance.protocols let protocols = instances()[instance.connection]?.protocols ?? instance.protocols
// What the socket trusts is what discovery just got through with: the login's CA, or the
// system's bundle when the instance's certificate needs it (Bun's own roots do not hold a
// private CA). Why discovery failed is why the dial will, said in the status.
let ca = loginCa
let why: string | undefined
let older: string | undefined
try { try {
const { discovery } = await discover(instance.base_url, ca ? { ca: instance.ca } : undefined) const found = await discover(instance.base_url, loginCa ? { ca: caPath! } : undefined)
protocols = spoken(discovery) protocols = spoken(found.discovery)
rememberProtocols(instance.connection, protocols) rememberProtocols(instance.connection, protocols)
} catch {} if (found.tls?.ca && found.tls.ca !== caPath) ca = readFileSync(found.tls.ca, "utf8")
const protocol = downgraded ? 1 : linkProtocol(protocols) } catch (e) {
why = (e as Error).message
// A server that answers and speaks another protocol: no dial would get further.
if (e instanceof ProtocolError) older = why
}
if (older) {
clearInterval(freshen)
return status("stopped", older)
}
const unreachable = [caGone && `the CA file given at login, ${caGone}, is not there`, why ?? "could not reach the instance"].filter(Boolean).join("; ")
const protocol = linkProtocol(protocols)
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean; changed?: boolean }>((resolve) => { 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
@@ -201,8 +220,8 @@ export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
// 4401/4403: the instance's word that this token is done; 1008 policy, likewise. // 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}` : ""})` : "could not reach the instance" }) resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : unreachable })
}) })
ws.addEventListener("error", () => {}) ws.addEventListener("error", () => {})
}) })
@@ -212,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`)
+4 -3
View File
@@ -14,6 +14,7 @@ import type { ImagePart } from "../provider/types.ts"
import { commandSet, sourcesOf } from "../session/commands.ts" import { commandSet, sourcesOf } from "../session/commands.ts"
import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts" import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts" import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
import { readLevel } from "../provider/effort.ts"
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts" import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts" import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts"
import type { SessionContext } from "../app.ts" import type { SessionContext } from "../app.ts"
@@ -390,7 +391,7 @@ export class Share {
// is the instance's. // is the instance's.
modelConnection: this.app.engine.model.connectionName, modelConnection: this.app.engine.model.connectionName,
mode: this.app.engine.mode, mode: this.app.engine.mode,
effort: this.app.engine.effort ?? "off", effort: this.app.engine.effort,
busy: Boolean(this.app.turns.current), busy: Boolean(this.app.turns.current),
explicit, explicit,
}, },
@@ -596,9 +597,9 @@ export class Share {
// same model as `deepseek/x`, and switching to it again would reset the effort. // same model as `deepseek/x`, and switching to it again would reset the effort.
if (typeof p?.model === "string" && p.model && this.app.canonicalRef(p.model) !== this.app.engine.model.ref) this.app.switchModel(p.model) if (typeof p?.model === "string" && p.model && this.app.canonicalRef(p.model) !== this.app.engine.model.ref) this.app.switchModel(p.model)
const effort = effortOf(p?.effort) const effort = effortOf(p?.effort)
if (effort !== undefined) this.app.engine.effort = effort === "off" ? null : effort if (effort !== undefined) this.app.engine.effort = readLevel(this.app.engine.model, effort)
this.hooks.changed() this.hooks.changed()
return { model: this.app.engine.model.ref, effort: this.app.engine.effort ?? "off" } return { model: this.app.engine.model.ref, effort: this.app.engine.effort }
}) })
} }
} }
+261 -89
View File
@@ -12,13 +12,12 @@ import { boardSummary } from "./project/board.ts"
import { unsafeProjectFile } from "./project/safe.ts" import { unsafeProjectFile } from "./project/safe.ts"
import { Bus, type Asker } from "./bus/index.ts" import { Bus, type Asker } from "./bus/index.ts"
import { loadConfig, type Loaded } from "./config/load.ts" import { loadConfig, type Loaded } from "./config/load.ts"
import type { Effort, Mode } from "./config/schema.ts" import type { Mode } from "./config/schema.ts"
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts" 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 { 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,17 +41,20 @@ 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 { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts"
import { readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts"
import { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
export interface AppOptions { export interface AppOptions {
cwd: string cwd: string
model?: string model?: string
mode?: Mode mode?: Mode
/** undefined = the model's default; "off" = none */ /** The effort level asked for (an effort, "off" or "default"); undefined, or one the model does
effort?: Effort | "off" * not offer: its start level. */
effort?: string
asker: Asker asker: Asker
store?: Store | false store?: Store | false
/** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */ /** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */
@@ -84,9 +86,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 +99,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 +151,38 @@ 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 session's team (spec 3.2.0, team.md): its rules and its members at work. */
team: TeamRuntime
/** 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",
} }
@@ -209,13 +219,10 @@ export function createApp(o: AppOptions): App {
// An untrusted project is read-only: plan mode, and its own config is ignored. // An untrusted project is read-only: plan mode, and its own config is ignored.
let mode: Mode = o.mode ?? loaded.config.mode ?? "manual" let mode: Mode = o.mode ?? loaded.config.mode ?? "manual"
if (trust === "readonly") mode = "plan" if (trust === "readonly") mode = "plan"
// `effort:` in config when this model takes it, else the model's own default. // `effort:` in config when this model offers that level, else the model's own start level
const startEffort = (m: ResolvedModel) => { // (harness models/schema.json `levels`).
const e = loaded.config.effort const startEffort = (m: ResolvedModel): Level => readLevel(m, loaded.config.effort)
if (e === "off") return null const effort: Level = o.effort !== undefined && isLevel(o.effort) && (levelsFor(model).levels as string[]).includes(o.effort) ? o.effort : startEffort(model)
return e && effortsFor(m).includes(e) ? e : defaultEffort(m)
}
const effort = o.effort === "off" ? null : (o.effort ?? startEffort(model))
// One object for the session's lifetime, so a setting changed later reaches the tools. // One object for the session's lifetime, so a setting changed later reaches the tools.
loaded.config.search ??= {} loaded.config.search ??= {}
const bus = o.bus ?? new Bus() const bus = o.bus ?? new Bus()
@@ -248,8 +255,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 +266,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 +298,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 +335,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, member?: { name: string; manager: string; role: string; goal: string; lead: boolean }) => {
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 +380,29 @@ 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,
...(member ? { member } : {}),
// The lead's prompt says how to lead whenever the team tools are offered (team.md §10).
teamLead: !subagent && teamRt.team.offered,
}) })
}
/** 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,19 +411,20 @@ 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`)
const vars: Record<string, Record<string, string>> = { const vars: Record<string, Record<string, string>> = {
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") }, task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
team_add: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
bash: { bash: {
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120), timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
timeout_max: inWords(600), timeout_max: inWords(600),
@@ -429,9 +472,15 @@ export function createApp(o: AppOptions): App {
// The model asked for (task's `model`), else the agent's own, else the session's — and whether // The model asked for (task's `model`), else the agent's own, else the session's — and whether
// its server can take it beside the session's and beside every subagent already running // its server can take it beside the session's and beside every subagent already running
// (src/session/capacity.ts), before anything is made. // (src/session/capacity.ts), before anything is made.
const member = opts.member
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
const refused = capacityRefusal(engine.model, m) || registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent).map((j) => capacityBeside(j.subagent!.engine.model, m)).find(Boolean) // A team's member is not refused for capacity: the team gave it its slot (team.md §8).
if (!member) {
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team)
const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length
const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean)
if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`) if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`)
}
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation). // A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
let wt: Worktree | undefined let wt: Worktree | undefined
if (opts.worktree || spec.isolation === "worktree") { if (opts.worktree || spec.isolation === "worktree") {
@@ -459,8 +508,14 @@ export function createApp(o: AppOptions): App {
} }
: perm : perm
// `report` is every subagent's: its way of speaking to the main agent before its final reply. // `report` is every subagent's: its way of speaking to the main agent before its final reply.
const childTools = [...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!spec.tools || spec.tools.includes(t.name))), ...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name))] // A team member also has team_send and team_read, and one that may lead team_add and team_stop
const description = opts.description ?? prompt.split("\n")[0]!.slice(0, 60) // (team.md §4); a task helper has none of them.
const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : []
const childTools = [
...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!(TEAM_TOOLS as readonly string[]).includes(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))),
...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name)),
]
const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60))
const ownerSession = jobsSessionId() const ownerSession = jobsSessionId()
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId) const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`) if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
@@ -486,7 +541,8 @@ export function createApp(o: AppOptions): App {
store, store,
sessionId: childRow?.id, sessionId: childRow?.id,
// An agent's own mode can make it stricter than its parent, never looser. // An agent's own mode can make it stricter than its parent, never looser.
mode: stricterMode(spec.mode, engine.mode), // A team member's is no looser than its manager's (team.md §4): a sub-manager's own mode.
mode: stricterMode(spec.mode, (member?.manager && member.manager !== TEAM.names.lead ? teamRt.handle(member.manager)?.engine.mode : undefined) ?? engine.mode),
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m), effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default, maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default,
compaction, compaction,
@@ -495,23 +551,37 @@ export function createApp(o: AppOptions): App {
...baseToolCtx(), ...baseToolCtx(),
...(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 }) },
...(member ? { team: { runtime: teamRt, as: member.name } } : {}),
}, },
fallback: engine.o.fallback, ...(member
? {
team: {
fix: () => teamRt.team.offered,
offered: () => teamRt.team.offered,
refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args),
},
}
: {}),
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
? `${spec.instructions}\n\nYou are working in a separate checkout of the repository, at ${at!.root}, on the branch ${wt.branch} (made from the last commit — uncommitted work of the main agent is not here). Work only inside it. When you finish, your changes are committed on that branch for the main agent to merge: do not commit, merge or push yourself.` ? `${spec.instructions}\n\nYou are working in a separate checkout of the repository — its root is your project root, so relative paths reach it — on the branch ${wt.branch} (made from the last commit — uncommitted work of the main agent is not here). Work only inside it. When you finish, your changes are committed on that branch for the main agent to merge: do not commit, merge or push yourself.`
: 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,
member ? { name: member.name, manager: member.manager ?? TEAM.names.lead, role: member.role, goal: member.goal, lead: member.leads } : undefined,
), ),
}) })
// Its own background commands wake it at its next step while it runs; once it has ended they // Its own background commands wake it at its next step while it runs; once it has ended they
@@ -527,7 +597,14 @@ export function createApp(o: AppOptions): App {
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) }) const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) } return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
} }
const run = (text: string) => child.prompt(text).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))) // A task helper started inside a team counts toward its size and holds a slot while it runs
// (team.md §3, §8).
const counted = (p: Promise<SubagentResult>) => {
if (member || !teamRt.team.offered) return p
const id = teamRt.team.helperStart(TEAM.names.lead, m.ref)
return p.finally(() => teamRt.team.helperDone(id))
}
const run = (text: string, machine = false) => counted(child.prompt(text, [], text, machine ? { machine: true } : {}).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))))
const handle: SubagentHandle = { const handle: SubagentHandle = {
bus: childBus, bus: childBus,
engine: child, engine: child,
@@ -535,7 +612,23 @@ export function createApp(o: AppOptions): App {
restart: (text) => run(text).then(() => undefined), restart: (text) => run(text).then(() => undefined),
cancel: () => child.cancel(), cancel: () => child.cancel(),
} }
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background }) job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background || member !== undefined })
if (member) {
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
job.team = member.name
const memberRun = (text: string, machine: boolean) => {
if (job.state !== "running") registry.restartSubagent(job)
void run(text, machine).then((r) => {
const leftover = [...child.takeNotes(), ...child.takeInbox()]
const reason = r.reason === "error" ? `error — ${r.error ?? "unknown"}` : r.reason
if (r.reason !== "cancelled") teamRt.memberDone(member.name, r.text, reason, leftover)
})
}
const mh: MemberHandle = { engine: child, run: (text) => memberRun(text, true), cancel: () => child.cancel() }
teamRt.attach(member.name, mh)
memberRun(prompt, false)
return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh }
}
// A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not. // A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not.
const onAbort = () => child.cancel() const onAbort = () => child.cancel()
opts.signal?.addEventListener("abort", onAbort, { once: true }) opts.signal?.addEventListener("abort", onAbort, { once: true })
@@ -561,21 +654,71 @@ export function createApp(o: AppOptions): App {
budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens }, budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
compaction, compaction,
perm, perm,
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } }, toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner }, team: { get runtime() { return teamRt }, as: TEAM.names.lead } },
system: (m, current, plan) => system(m, current, undefined, undefined, plan), system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
team: {
fix: (ref, offered) => teamRt.team.fix(ref, offered),
offered: () => teamRt.team.offered,
refuse: (tool, args) => teamRt.refuse(TEAM.names.lead, tool, args),
note: () => teamRt.team.noteLines(),
forget: () => teamRt.team.forgetTold(),
beforeTurn: (signal) => teamRt.beforeLeadTurn(signal),
},
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, // subagents: false — no task tool, and no team (team.md §2).
fallback(current, tried) { spawn: loaded.config.subagents === false ? undefined : spawn,
for (const ref of current.spec.fallback ?? []) { })
if (tried.has(ref)) continue
// The session's team (spec 3.2.0, team.md): its size from the configuration (the cap the global
// one's), the capacity of each model as the server says it.
const capacityOf = (ref: string): Capacity => {
try { try {
const m = resolveModel(loaded, ref) const s = ref === engine.model.ref ? engine.model.spec : resolveModel(loaded, ref).spec
return { model: m, client: clientFor(m), effort: startEffort(m) } return { parallel: s.parallel, single_session: s.single_session, group: s.group }
} catch {} } catch {
return {}
}
}
// The size as set while the process runs (any scope): a new or resumed session keeps it.
const teamSize: { min?: number; max?: number } = {}
const newTeam = () =>
new Team({
min: teamSize.min ?? loaded.config.agents?.min,
max: teamSize.max ?? loaded.config.agents?.max,
cap: loaded.config.agents?.cap,
subagents: loaded.config.subagents !== false,
model: engine.model.ref,
capacity: capacityOf,
leadState: "idle",
readingBash: (command) => readingBashByDefault(command, project.root),
})
const teamRt: TeamRuntime = new TeamRuntime({
team: newTeam(),
bus,
// spawn makes the member's engine and attaches it before its first await, so a message to it
// finds it at once.
spawnMember: (member: TeamAgent, text: string) => {
spawn(member.agent, text, { member, model: member.model, background: true, description: `${member.name}: ${member.role}` }).catch((e) => {
bus.emit({ type: "notice", level: "warn", message: `team: ${member.name} could not start: ${(e as Error).message}` })
teamRt.memberDone(member.name, "", `error — ${(e as Error).message}`, [])
})
},
lead: { note: (text) => engine.note(text), wake: (text) => wake(text, shownLine(text)) },
resolve: (agent, model) => {
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
try {
return { model: model ? resolveModel(loaded, model).ref : engine.model.ref }
} catch (e) {
return { error: (e as Error).message }
} }
return undefined
}, },
}) })
// The lead's turn ended: it is idle, and its slot frees (team.md §8).
bus.on((e) => e.type === "done" && teamRt.leadDone())
// 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).
let mcpNames = new Set<string>() let mcpNames = new Set<string>()
@@ -596,6 +739,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 +801,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(".")
@@ -680,27 +829,43 @@ export function createApp(o: AppOptions): App {
}, },
live(key) { live(key) {
if (key === "model") return engine.model.ref if (key === "model") return engine.model.ref
if (key === "effort") return engine.effort ?? "off" if (key === "effort") return engine.effort
if (key === "mode") return engine.mode if (key === "mode") return engine.mode
// The team's size as the session holds it (the cap applied).
if (key === "agents.min") return teamRt.team.min
if (key === "agents.max") return teamRt.team.max
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config) return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
}, },
check(key, v) { check(key, v) {
if (key === "model" || key === "small_model") resolveModel(loaded, String(v)) if (key === "model" || key === "small_model") resolveModel(loaded, String(v))
if (key === "effort" && v !== "off" && !effortsFor(engine.model).includes(v as Effort)) if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v)))
throw new Error(`${engine.model.ref} takes ${effortsFor(engine.model).join(", ")} or off`) throw new Error(`${engine.model.ref} offers the levels ${levelsFor(engine.model).levels.join(", ")}`)
if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`) if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`)
// The team's size (team.md §2): refused outside 1 ≤ min ≤ max ≤ cap, with the bounds.
if (key === "agents.min" || key === "agents.max") {
const t = teamRt.team
const no = Team.check(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max, t.cap)
if (no) throw new Error(no)
}
}, },
apply(key, v) { apply(key, v) {
if (key === "model") switchModel(String(v)) if (key === "model") switchModel(String(v))
else if (key === "effort") engine.effort = v === "off" ? null : (v as Effort) else if (key === "effort") engine.effort = v as Level
else if (key === "mode") engine.mode = v as Mode else if (key === "mode") engine.mode = v as Mode
else if (key === "limits.steps") engine.o.maxSteps = Number(v) else if (key === "limits.steps") engine.o.maxSteps = Number(v)
else if (key === "busy_input") engine.busyInput = v as "steer" | "queue" else if (key === "busy_input") engine.busyInput = v as "steer" | "queue"
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
else if (key === "agents.min" || key === "agents.max") {
// Said to the lead as a note on its next message (team.md §2), never in the prompt.
const t = teamRt.team
t.setSetting(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max)
teamSize.min = t.min
teamSize.max = t.max
bus.emit({ type: "team" })
}
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 })
}, },
@@ -752,6 +917,7 @@ export function createApp(o: AppOptions): App {
mcpReady, mcpReady,
small, small,
close: () => { close: () => {
teamRt.stopAll()
detach() detach()
for (const d of childDetach.splice(0)) d() for (const d of childDetach.splice(0)) d()
return mcp.close() return mcp.close()
@@ -766,15 +932,17 @@ export function createApp(o: AppOptions): App {
jobsSessionId, jobsSessionId,
jobs: () => registry.list(jobsSessionId()), jobs: () => registry.list(jobsSessionId()),
wake, wake,
team: teamRt,
get context() { get context() {
return context return context
}, },
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,11 +952,13 @@ 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)
keepCwd(s?.id) keepCwd(s?.id)
standIn = `local_${crypto.randomUUID().slice(0, 8)}` standIn = `local_${crypto.randomUUID().slice(0, 8)}`
teamRt.reset(newTeam())
engine.newSession(s?.id) engine.newSession(s?.id)
turns.reset() turns.reset()
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode }) if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
@@ -801,6 +971,8 @@ 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()
teamRt.reset(newTeam())
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 = ""
+3
View File
@@ -62,6 +62,9 @@ export type Event =
/** The note about that job was given to the model: inside a turn at its next step, or as the /** The note about that job was given to the model: inside a turn at its next step, or as the
* `machine` turn whose `prompt` follows. */ * `machine` turn whose `prompt` follows. */
| { type: "job_note"; jobId: string; text: string } | { type: "job_note"; jobId: string; text: string }
/** The session's team changed (spec 3.2.0): a member added, started, finished or stopped, a
* message in the room. Read the team itself for what. */
| { type: "team" }
/** The permission mode changed from inside a turn (plan approved). */ /** The permission mode changed from inside a turn (plan approved). */
| { type: "mode"; mode: Mode } | { type: "mode"; mode: Mode }
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings /** The session's model or effort changed, for whatever reason — /model, /effort, the settings
+46 -33
View File
@@ -5,14 +5,14 @@ import { parseArgs } from "node:util"
import { createApp } from "./app.ts" import { createApp } from "./app.ts"
import { attachmentsFor } from "./project/attach.ts" import { attachmentsFor } from "./project/attach.ts"
import { ConfigError, loadConfig } from "./config/load.ts" import { ConfigError, loadConfig } from "./config/load.ts"
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "./config/schema.ts" import { asMode, EFFORTS, MODES, type Mode } from "./config/schema.ts"
import { isLocal, localRoot } from "./config/paths.ts" import { isLocal, localRoot } from "./config/paths.ts"
import { SettingError } from "./config/settings.ts" import { SettingError } from "./config/settings.ts"
import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts" import { renderJson, renderPlain, settleJobs, terminalAsker } from "./headless.ts"
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 { isLevel, levelsFor } 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,
@@ -81,7 +84,7 @@ Local mode:
Options for run: Options for run:
-m, --model conn/model model to use (default: config.yaml \`model\`) -m, --model conn/model model to use (default: config.yaml \`model\`)
--mode MODE manual | edit | auto | plan --mode MODE manual | edit | auto | plan
-e, --effort LEVEL ${EFFORTS.join(" | ")} | off -e, --effort LEVEL ${EFFORTS.join(" | ")} | off | default (one the model offers)
-y, --yes approve every ask (still never the hardline) -y, --yes approve every ask (still never the hardline)
--json events as JSON lines --json events as JSON lines
--reasoning show the model's reasoning --reasoning show the model's reasoning
@@ -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))
@@ -213,7 +240,7 @@ async function main() {
cwd: process.cwd(), cwd: process.cwd(),
model: values.model, model: values.model,
mode: asMode(values.mode), mode: asMode(values.mode),
effort: values.effort as Effort | "off" | undefined, effort: values.effort,
resume: values.session ?? (values.continue ? "last" : undefined), resume: values.session ?? (values.continue ? "last" : undefined),
}) })
if (done.reload) await (await import("./reload.ts")).restart(done.reload) if (done.reload) await (await import("./reload.ts")).restart(done.reload)
@@ -272,14 +299,13 @@ async function main() {
if (!prompt && !process.stdin.isTTY) prompt = (await new Response(Bun.stdin.stream()).text()).trim() if (!prompt && !process.stdin.isTTY) prompt = (await new Response(Bun.stdin.stream()).text()).trim()
if (!prompt) fail('nothing to do: lembas run "your prompt"', 2) if (!prompt) fail('nothing to do: lembas run "your prompt"', 2)
if (values.mode && !asMode(values.mode)) fail(`--mode must be one of ${MODES.join(", ")}`, 2) if (values.mode && !asMode(values.mode)) fail(`--mode must be one of ${MODES.join(", ")}`, 2)
if (values.effort && values.effort !== "off" && !(EFFORTS as readonly string[]).includes(values.effort)) if (values.effort && !isLevel(values.effort)) fail(`--effort must be one of ${EFFORTS.join(", ")}, off, default`, 2)
fail(`--effort must be one of ${EFFORTS.join(", ")}, off`, 2)
const app = createApp({ const app = createApp({
cwd: process.cwd(), cwd: process.cwd(),
model: values.model, model: values.model,
mode: asMode(values.mode), mode: asMode(values.mode),
effort: values.effort as Effort | "off" | undefined, effort: values.effort,
asker: terminalAsker({ yes: values.yes ?? false, unattended: loadConfig().config.question?.unattended, theme: loadConfig().config.theme }), asker: terminalAsker({ yes: values.yes ?? false, unattended: loadConfig().config.question?.unattended, theme: loadConfig().config.theme }),
unattended: !values.yes && !process.stdin.isTTY, unattended: !values.yes && !process.stdin.isTTY,
store: values["no-store"] ? false : undefined, store: values["no-store"] ? false : undefined,
@@ -305,33 +331,20 @@ 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}` : "",
spec.vision ? "vision" : "", spec.vision ? "vision" : "",
spec.tools === false ? "no-tools" : "", spec.tools === false ? "no-tools" : "",
spec.effort || spec.efforts ? `effort ${spec.effort ?? "off"} of ${effortsFor(m).join("/")}` : "", ((l) => (l.control === "none" ? "" : `effort ${l.start} of ${l.levels.join("/")}`))(levelsFor(m)),
spec.parallel ? `parallel ${spec.parallel}` : spec.single_session ? "parallel 1" : "",
].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}`)
+1 -1
View File
@@ -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."),
} }
} }
+46 -31
View File
@@ -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") {
@@ -146,6 +154,12 @@ function readConfig(file: string, label: string, notes: string[], dropped?: stri
delete all[k] delete all[k]
dropped.push(k) dropped.push(k)
} }
// The team's cap is the global configuration's: a project never raises it (harness team.md §2).
if (dropped && all.agents && typeof all.agents === "object" && !Array.isArray(all.agents) && "cap" in all.agents) {
const { cap: _cap, ...agents } = all.agents as Record<string, unknown>
all.agents = agents
dropped.push("agents.cap")
}
// mcp, voice and search are filled in later, one server / half / service at a time: a key that // mcp, voice and search are filled in later, one server / half / service at a time: a key that
// cannot be read turns off that one thing instead of the whole file. // cannot be read turns off that one thing instead of the whole file.
// update: filled in when an update is looked for — a token file missing must not stop a start. // update: filled in when an update is looked for — a token file missing must not stop a start.
@@ -262,41 +276,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 +409,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 +420,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 +489,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
} }
+49 -39
View File
@@ -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. */
@@ -52,37 +51,23 @@ export const ModelSpec = z.strictObject({
max_output: z.number().int().positive().optional().describe("Max tokens per reply."), max_output: z.number().int().positive().optional().describe("Max tokens per reply."),
temperature: z.number().min(0).max(2).optional().describe("Sampling temperature; absent = the server's default."), temperature: z.number().min(0).max(2).optional().describe("Sampling temperature; absent = the server's default."),
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. [] — it takes none (its one level is `default`)."),
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("Default effort; `off` sends none."), thinking_switch: z.boolean().optional().describe("The server turns thinking off for a request with chat_template_kwargs.enable_thinking false: only then is the level `off` offered."),
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: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The level a conversation starts with, when the model offers it: an effort, `off` (thinking switched off, with thinking_switch) or `default` (nothing sent, for a model that takes no efforts)."),
effort_map: z.partialRecord(z.enum(EFFORTS), z.number().int().positive()).optional().describe("anthropic/gemini: effort → thinking budget tokens."), effort_style: z.enum(["top", "kwargs", "both"]).optional().describe("Where the effort goes in a request: top-level, in chat_template_kwargs, or both (the default: llama.cpp drops top-level)."),
vision: z.boolean().optional().describe("The model reads images: @image files, pasted paths and view_image."), 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. The same as parallel: 1."),
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."), parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2."),
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 +75,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 +100,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 +232,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 +277,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 +294,21 @@ 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"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."),
agents: z
.strictObject({
min: z.number().int().min(1).optional().describe("The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start."),
max: z.number().int().min(1).optional().describe("The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper."),
cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."),
})
.optional()
.describe("Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session."),
subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."),
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."),
+6 -4
View File
@@ -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 of the model's levels; 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). */
@@ -36,7 +36,10 @@ const USER: Scope[] = ["session", "global"]
export const SETTINGS: SettingDef[] = [ export const SETTINGS: SettingDef[] = [
{ key: "model", kind: "model", agent: true, scopes: ALL }, { key: "model", kind: "model", agent: true, scopes: ALL },
{ key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's default" }, { key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" },
// The team's size is the person's: the agent never sets how many agents it may have.
{ key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1" },
{ key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4" },
{ key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" }, { key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" },
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" }, { key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" }, { key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
@@ -53,7 +56,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" },
@@ -105,7 +107,7 @@ export function parseValue(def: SettingDef, raw: unknown): unknown {
if (def.key === "mode") v = asMode(v) ?? v if (def.key === "mode") v = asMode(v) ?? v
// "none" is how a dialog or a person says it; the file keeps the web UI's empty string. // "none" is how a dialog or a person says it; the file keeps the web UI's empty string.
if (def.key === "personality" && typeof v === "string" && ["none", "off", "default"].includes(v.toLowerCase())) v = "" if (def.key === "personality" && typeof v === "string" && ["none", "off", "default"].includes(v.toLowerCase())) v = ""
if (def.kind === "effort" && v !== "off" && !(EFFORTS as readonly string[]).includes(String(v))) throw new SettingError(`effort: one of ${EFFORTS.join(", ")} or off`) if (def.kind === "effort" && !["off", "default", ...EFFORTS].includes(String(v))) throw new SettingError(`effort: one of ${EFFORTS.join(", ")}, off or default`)
const schema = schemaAt(def.key.split(".")) const schema = schemaAt(def.key.split("."))
const r = schema?.safeParse(v) const r = schema?.safeParse(v)
if (!r || !r.success) throw new SettingError(`${def.key}: ${def.values ? `one of ${def.values.join(", ")}` : (r?.error.issues.map((i) => i.message).join("; ") ?? "not settable")}`) if (!r || !r.success) throw new SettingError(`${def.key}: ${def.values ? `one of ${def.values.join(", ")}` : (r?.error.issues.map((i) => i.message).join("; ") ?? "not settable")}`)
+92 -3
View File
@@ -7,6 +7,8 @@ import LOOP_JSON from "../harness/loop.json"
import t_apply_patch from "../harness/tools/apply_patch.json" import t_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,22 +21,37 @@ 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_team_add from "../harness/tools/team_add.json"
import t_team_read from "../harness/tools/team_read.json"
import t_team_send from "../harness/tools/team_send.json"
import t_team_stop from "../harness/tools/team_stop.json"
import t_view_image from "../harness/tools/view_image.json" import t_view_image from "../harness/tools/view_image.json"
import t_web_fetch from "../harness/tools/web_fetch.json" import t_web_fetch from "../harness/tools/web_fetch.json"
import t_web_search from "../harness/tools/web_search.json" import t_web_search from "../harness/tools/web_search.json"
@@ -43,9 +60,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 +79,16 @@ 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).
* `team` (3.2.0): the team setting allows more than one agent (harness/team.md §2). */
requires?: ("display" | "page" | "forge" | "plan" | "team")[]
/** 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 +97,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 +118,57 @@ export const LOOP: {
running_max: { default: number } running_max: { default: number }
subagent_steps: { default: number } subagent_steps: { default: number }
} }
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
team: {
agents: { min: number; max: number; cap: number }
names: { pattern: string; lead: string; reserved: string[] }
marks: { message: string; report: string }
unavailable: { one_agent: string; one_request: string }
work: { write: string[]; execute: string[]; kept: string[] }
read_max: number
texts: Record<"added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended", string>
read: { tree: string; agent: string; lead_role: string; states: Record<"working" | "waiting" | "finished" | "idle", string>; active: string; room: string; message: string; report: string; none: string; more: string }
}
/** 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
/** The environment block's lines about where an agent session works (spec 3.2.0, §11). */
env: { project: string; started: 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
/** Spec 3.2.0 (team.md §10): the size setting, said to the lead. */
team: string
team_lead: string
team_one: string
team_stopped: 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 +245,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_team_add, t_team_read, t_team_send, t_team_stop, 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). */
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more