Clone
1
Architecture
Jaroslav Beneš edited this page 2026-10-09 22:40:23 +00:00

Architecture

One process per front end, one event bus per session. createApp (in src/app.ts) wires configuration, the project, the model and an Engine; the TUI, lembas run, the ACP agent and the tests all start there and subscribe to the same events.

Layout of src/

cli.ts             argv: the TUI, or run | models | sessions | config (check|schema|list|get|set)
                   | login | logout | serve --stdio | service | mcp | voice | kb | trust | update
                   | uninstall
app.ts             wiring shared by every front end: config → project → model → Engine; spawn
                   (subagents), the live Settings, freeze() of memory and skills per session
headless.ts        `lembas run "…"`: answer on stdout, tool lines on stderr, or --json lines
settings.ts        a setting's value and source; set for session / global / project
reload.ts          /reload: execv the binary on disk now, back on the same session
doctor.ts          `lembas config check`: machine checks (another `lembas` first on PATH, …)
service.ts         `lembas service`: the link as a systemd *user* unit, one copy at a time
uninstall.ts       `lembas uninstall [--purge]`
harness.ts         the harness spec embedded at build time: version, tool catalogue
version.ts         replaced at compile time

bus/               the typed event bus, and the Asker interface (who answers approvals,
                   questions and plans)
config/            XDG paths (LEMBAS_HOME for tests), YAML loading and layering, {env:}/{file:}
                   substitution and key_cmd, the zod schema, the settings list, JSON Schemas
provider/          types.ts (Message, StreamEvent), common.ts (retry rule, part builder, stream
                   recorder), sse.ts, think.ts (inline <think> splitter), effort.ts, learned.ts,
                   discover.ts (context windows), http.ts (TLS, auth), tokens.ts, refs.ts (how a
                   model is named); one client per dialect: openai-chat.ts, responses.ts,
                   anthropic.ts, gemini.ts, ollama.ts
permission/        wildcard.ts, arity.ts (OpenCode), bash.ts (the small shell reader),
                   hardline.ts (the floor), evaluate.ts (rules, modes, paths)
tool/              tool.ts (the framework), registry.ts, names.ts (former names); read, write,
                   edit, multiedit, apply_patch (+ patch.ts, unidiff.ts, replace.ts), search.ts
                   (list, glob, grep), bash + jobs, web, question (ask_user), plan_exit
                   (plan_submit), todo, task, memory, skills, library, session_search,
                   settings, view_image, project_tools (tasks, decisions)
session/           engine.ts (the loop), store.ts (SQLite + FTS5), turns.ts (undo/redo),
                   title.ts, context.ts (/context), capacity.ts (the capacity rule),
                   commands.ts (slash commands shared by the TUI and ACP)
prompt/            assemble.ts (the system prompt), personality.ts
project/           root.ts (.agent, or .lembas), init.ts (first open: trust, git, skeleton),
                   safe.ts (no writes through a committed symlink), attach.ts (@ references),
                   files.ts (the @ index), commands.ts, agents.ts, board.ts, image.ts
git/               run.ts, repo.ts, snapshot.ts (the shadow store), branch.ts, commit.ts,
                   release.ts, worktree.ts
memory/            store.ts (MEMORY.md, USER.md), threats.ts (the injection scanner)
skill/             SKILL.md directories, the skills block of the prompt
library/           store.ts (notes, bases, documents, chunks; FTS5 + vectors), chunks.ts,
                   embed.ts, ingest.ts, cli.ts (`lembas kb`)
mcp/               index.ts (McpManager: stdio and HTTP servers), oauth.ts
search/            webui (a LLeMbas instance's), searxng, firecrawl, ddg; fetch.ts (pages)
voice/             audio.ts (recorders and players), speech.ts, text.ts, index.ts
lembas/            a LLeMbas instance: client.ts (discovery, device login), login.ts, webui.ts
                   (the `webui` connection), personal.ts (personalization, library, usage)
acp/               agent.ts, rpc.ts, link.ts, hub.ts, share.ts, prompt.ts, turns.ts,
                   pending.ts, shellrc.ts — see below
update/            index.ts, source.ts, sshsig.ts, version.ts — see below
tui/               OpenTUI + Solid — see below

Outside src/: harness/ (the shared spec), schema/ (JSON Schemas for the two YAML files, from bun run schema), scripts/ (build, dist, smoke, drive, ci, licences, harness), install.sh and get.sh, third_party/.

The engine loop (src/session/engine.ts)

  1. Stream a reply from the provider; forward text, reasoning and tool-call deltas to the bus.
  2. No tool calls → done. Otherwise decide every call first (asking is sequential): parse and validate the arguments, build the tool's permission request, evaluate it, ask when needed. The same call three times running is always asked about.
  3. Run what was allowed: consecutive ordinary calls together, up to four; an exclusive tool (such as ask_user) alone; two calls on the same file in order. Errors become tool results, never exceptions.
  4. Append the results and go round again.

Around that: messages sent meanwhile wait in the engine's inbox and enter after the step's tool results as one user message marked STEER_MARK (busy_input: queue holds them for the next prompt instead). Stop keeps what streamed, so /continue picks up there. Budgets (harness/loop.json: wall time, tool output bytes, written tokens) and the step ceiling withdraw the tools and ask for an answer from what the model has. Context: before each step, past compaction.auto_at of the window, old tool outputs are pruned, then — if still over — the history is summarised and the turn's prompt put back verbatim. Capacity (session/capacity.ts) refuses a subagent on a model its server cannot hold beside the session's own.

Permission evaluation (src/permission/evaluate.ts)

Every path is judged where it really is (realPath).

  1. Hardline: the raw line, then each simple command in its plain spelling (plainCommands: VAR= prefixes, wrappers such as sudo -u x, env -i, timeout 5, xargs taken off; quotes and program paths removed; sh -c "…" read as the line it runs); protected paths for writes. Deny.
  2. Rules: defaults → global → project (trusted only) → session approvals. Per pattern the last match wins, across patterns the strictest; an approval never beats a written deny, and a deny on the plain spelling is a deny. A bash line is split into simple commands; command substitution, eval, nested shells or a redirect to a file turn an allow into ask. Files a command names follow the read rules and the project boundary.
  3. A path outside the project goes through external_directory.
  4. Mode: auto allows; plan allows reads and writes only under the project's plans/; edit allows file reads and writes inside the project (not .git/, not the project's own config, agents, commands and skills); manual is the rules as they are. A subagent can be stricter than its parent, never looser.

"Always allow" covers the command's arity prefix (git commit -m x → git commit *); a command that runs others is approved exactly, and a line where one runs others offers no "always".

Providers (src/provider/)

Every dialect turns the provider-neutral Message[] into its request and its stream into the same StreamEvents (text, reasoning, tool_call_delta, usage, progress, notice, finish). What a provider needs back next turn travels on the parts: Anthropic thinking signatures, a Responses reasoning item, Gemini's thought signature. A part another provider cannot take is dropped on conversion, never sent malformed. common.retrying() owns the retry rule: only while nothing of the reply has been passed on, plus one attempt for a server error. effort.ts and learned.ts remember an effort a server refused.

The session store and turns

session/store.ts is ~/.local/share/lembas/sessions.db (bun:sqlite), sessions and messages with an FTS5 index for session_search and /sessions. Each prompt is a turn (session/turns.ts): a snapshot before and after in a shadow git repository (git/snapshot.ts, under ~/.local/share/lembas/snapshot/, borrowing the project's objects, skipping .git, node_modules, the project's local/ and files over 2 MB, off above 20 000 files). /undo restores exactly the paths the turn changed and drops it from the conversation; /redo reverses that; /checkpoint keeps a tree under a name with its objects packed so a git gc in the project cannot orphan it. Titles are set from the first prompt at once and improved by the model after the first reply.

The system prompt (src/prompt/assemble.ts)

Identity → base and family overlay → environment, model roster, git state, task board → AGENTS.md / CLAUDE.md and instruction_files → MCP server instructions (scanned) → memory guidance and the memory snapshot → the skills block → the mode → the person's custom instructions → the personality. The last two use the same headings and order as the LLeMbas web UI. Memory and skills are frozen per session (freeze() at start, /new and resume), so a save during the session does not change the prompt under it and the provider's prompt cache stays valid.

The TUI (src/tui/)

A subscriber like any other. state.ts turns bus events into a list of items, buffering streamed text and flushing about 30 times a second; the permission asker is a store entry whose resolve a card calls. index.tsx builds the renderer and gives the terminal back before any fatal error; app.tsx is Root (layout, keys, dialogs); components/ holds the prompt, messages, the permission, question and plan cards, the spinner, the status bar, the todo list, the first-run and setup screens; commands.ts the built-in slash commands; palette.ts and theme.ts every colour; clipboard.ts copies by OSC 52 and the system clipboard.

  • agent.ts — the CLI as an ACP agent: lembas serve --stdio for an editor, and the far end of the LLeMbas link. A session is an ordinary session (createApp): same tools, permissions, prompts and hardline as the TUI. A client that says it is LLeMbas also gets every engine event as _lembas/event. Limits are the device's: the global remote: block's roots, max_mode, trust and approval timeout.
  • rpc.ts — JSON-RPC 2.0, one message a line on stdio and one per WebSocket frame on the link; written here so its few rules are what LLeMbas implements in Python too.
  • link.ts — the machine dials out to the instance it is logged in to; one connection per device, re-dialled with backoff. Nothing listens.
  • hub.ts — one session, seen from the terminal and the web alike. The process holding the link (the service, or a terminal sharing with /remote) is the hub, on a Unix socket in the state directory (mode 0600). A web-started session runs in the hub; opening it in a terminal asks the hub to let go.
  • share.ts — a terminal's side of the hub: its session shown in the web UI, the web as a second keyboard; an approval asked on both sides, first answer wins.
  • prompt.ts — a web prompt made into what the TUI would have sent: /name through session/commands.ts, @path through the TUI's expander, images and files stored as attachments (10 MB a block, 25 MB a prompt).
  • turns.ts — turn ids, the last 200 per session in a file shared by the service and a terminal, so a prompt is never run twice across a handover or a restart; _lembas/session/status reports running, queued and last finished.
  • pending.ts — deletes the instance has not acknowledged, resent.
  • shellrc.ts — shell integration for a terminal opened from the web: OSC 133 and OSC 7 marks for bash, zsh and fish.

The whole protocol, every _lembas/* method and capability, is harness/acp.md. lembas/client.ts finds an instance (/.well-known/lembas.json) and signs in by device code (RFC 8628); lembas/webui.ts reads the account's models from /v1/models at every start.

Updates and signing (src/update/)

source.ts lists releases from GitHub's or Gitea's API or a static directory (default: the public GitHub repository). version.ts compares SemVer; a channel is stable or beta. index.ts installs: fetch SHA256SUMS and SHA256SUMS.sig; sshsig.ts checks the SSH signature itself (PROTOCOL.sshsig, namespace lembas-release, a key from RELEASE_KEYS or update.public_key, ed25519 over the hashed list), with no ssh-keygen needed; the list's first line must name the release's version; the asset must match its checksum; the unpacked binary must report that version. The running binary is hard-linked to .prev and the new one renamed over it; the running process keeps its inode. A build from source is never updated, and nothing older than what runs is installed unless asked for by name. RELEASE_KEYS is a list so the key can be rotated: a release signed by the old key ships the new one.

The service (src/service.ts)

lembas service install writes ~/.config/systemd/user/lembas.service and starts it; status, logs, uninstall, and run (what the unit runs: the link and the hub, in the foreground). It runs as the user, never root, listens on nothing, and says — rather than does — that lingering is needed to keep it running while nobody is logged in.

Library, memory, skills, MCP, voice

  • Memory is two small files (MEMORY.md, USER.md) under the config directory, written by the memory tool and scanned by memory/threats.ts. Skills are SKILL.md directories; only names and first lines enter the prompt, and skill_manage is all-or-nothing across the files it touches.
  • The library is one SQLite file: notes, knowledge bases, documents and chunks (LLeMbas's chunker, shared by conformance), bm25 plus optional vectors, fused by reciprocal rank. Logged in with library: lembas, the account's library on the instance is used over /mcp instead.
  • MCP: McpManager connects every server at once without blocking the TUI; a server that connects mid-session is usable from the next prompt. Stdio servers get a minimal environment; HTTP servers try streamable HTTP, then SSE; OAuth is the SDK's flow with tokens in mcp-auth.json (mode 600).
  • Voice: every recorder delivers raw 16 kHz mono s16le on stdout, so the level meter and silence detection work the same; the recorder is its own process group and is killed with it.