LLeMbas CLI 1.0.0
ci / check (push) Waiting to run

The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a
link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64
and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+1167
View File
File diff suppressed because it is too large. Load diff
+340
View File
@@ -0,0 +1,340 @@
// The hub: one session, seen and used from the terminal and the web UI alike.
//
// A session runs in exactly one process — whichever has it open — and every other side reaches it
// through that one. The process holding the LLeMbas link (the service, or a terminal that shared a
// session with /remote while no service ran) is the hub: it listens on a Unix socket in the state
// directory, and every terminal on this machine connects to it.
//
// - A session the web UI started runs in the hub. Opening it in a terminal asks the hub to let go
// of it first (refused while it is working), and the terminal carries it on from the store —
// the same sessions.db, so nothing is lost and nothing is forked.
// - A session a terminal runs is *shared*: the hub tells the instance about it, passes on every
// event it makes, and hands it what the web UI sends (a prompt, a message mid-task, stop, mode,
// model). An approval goes to both sides and the first answer counts.
// - With the service running, every terminal session is shared by itself, within the device's
// own `remote:` limits. Without it, /remote shares the one session, and the terminal holds the
// link itself — so that session lasts on the web exactly as long as the terminal does.
//
// The socket is the user's own (0600, in ~/.local/state/lembas). It answers JSON-RPC, one
// message a line, the same framing as `serve --stdio`.
import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs"
import { connect, createServer, type Server, type Socket } from "node:net"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
import type { Mode } from "../config/schema.ts"
import type { CommandInfo } from "../session/commands.ts"
import { clampMode, refusalFor, type AcpAgent, type Limits } from "./agent.ts"
import { addPendingDeleted } from "./pending.ts"
import { INVALID_PARAMS, Peer, RpcError, type Transport } from "./rpc.ts"
export const hubSocket = () => join(paths.state, "hub.sock")
/** One message a line, over a Unix socket. */
export function socketTransport(sock: Socket): Transport {
let onMsg: (t: string) => void = () => {}
let onEnd: () => void = () => {}
let buf = ""
let ended = false
sock.setEncoding("utf8")
sock.on("data", (chunk: string) => {
buf += chunk
let nl: number
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
if (line.trim()) onMsg(line)
}
})
const end = () => {
if (ended) return
ended = true
onEnd()
}
sock.on("close", end)
sock.on("error", () => {})
return {
send: (t) => void (sock.writable && sock.write(t + "\n")),
onMessage: (fn) => (onMsg = fn),
onClose: (fn) => (onEnd = fn),
close: () => sock.destroy(),
}
}
/** Whether something answers on the socket (a hub that is alive, not a file left behind). */
export function answers(path = hubSocket(), timeoutMs = 1000): Promise<boolean> {
return new Promise((resolve) => {
const sock = connect(path)
const done = (v: boolean) => {
clearTimeout(timer)
sock.destroy()
resolve(v)
}
const timer = setTimeout(() => done(false), timeoutMs)
sock.once("connect", () => done(true))
sock.once("error", () => done(false))
})
}
/** Connect to the hub, if one runs. */
export function connectHub(path = hubSocket()): Promise<Peer | undefined> {
if (!existsSync(path)) return Promise.resolve(undefined)
return new Promise((resolve) => {
const sock = connect(path)
const timer = setTimeout(() => (sock.destroy(), resolve(undefined)), 2000)
sock.once("connect", () => {
clearTimeout(timer)
resolve(new Peer(socketTransport(sock)))
})
sock.once("error", () => {
clearTimeout(timer)
resolve(undefined)
})
})
}
/** A session a terminal runs and the hub passes on. */
export interface Shared {
sessionId: string
/** The project's root: what the limits judge, and what the instance is told. */
cwd: string
/** The directory it works in, which @path mentions resolve against. */
workdir: string
title: string
model: string
/** The connection its model is spoken to through. */
modelConnection?: string
mode: Mode
effort: string
/** /remote: the person shared it by name, so the device's remote limits do not decide. */
explicit: boolean
busy: boolean
client: Peer
pid: number
/** Its turns as its events said them: the one running, the last one done. */
turnId?: string
lastTurnId?: string
/** Its command set, as the terminal sent it. */
commands?: CommandInfo[]
}
export interface HubOptions {
/** The service: every terminal session is shared by itself (within the limits). A terminal that
* became the hub for /remote shares only what is shared by name. */
service: boolean
limits: Limits & { enabled: boolean }
}
export class Hub {
readonly shared = new Map<string, Shared>()
/** The sessions the link runs here: kept across links, so one that drops and comes
* back finds them still working. */
readonly held = new Map<string, import("./agent.ts").Session>()
/** The ACP agent on the link while it is up. */
agent?: AcpAgent
private server?: Server
private clients = new Set<Peer>()
constructor(readonly o: HubOptions) {}
/** Take the socket. False: another hub is alive on it. */
async listen(path = hubSocket()): Promise<boolean> {
mkdirSync(paths.state, { recursive: true })
if (existsSync(path)) {
if (await answers(path)) return false
rmSync(path, { force: true })
}
return new Promise((resolve) => {
const server = createServer((sock) => this.accept(new Peer(socketTransport(sock))))
server.once("error", () => resolve(false))
server.listen(path, () => {
try {
chmodSync(path, 0o600)
} catch {}
this.server = server
this.path = path
resolve(true)
})
})
}
private path?: string
close() {
for (const c of this.clients) c.close()
this.server?.close()
if (this.path) rmSync(this.path, { force: true })
this.server = undefined
}
/** The link came up: the instance hears about every shared session. */
attach(agent: AcpAgent) {
this.agent = agent
for (const s of this.shared.values()) this.announce(s)
// Their command sets go once the client has initialized the link (agent.ts, initialize).
}
detach(agent: AcpAgent) {
if (this.agent === agent) this.agent = undefined
}
/** Whether anything works here now: a session the link runs, or one a terminal shares mid-turn. */
busy(): boolean {
return [...this.held.values()].some((s) => s.prompting || s.queue) || [...this.shared.values()].some((s) => s.busy)
}
private announce(s: Shared) {
this.agent?.notifyInstance("_lembas/session/announce", {
sessionId: s.sessionId,
cwd: s.cwd,
title: s.title,
model: s.model,
// The session's model is this link's instance's.
instance: this.agent?.isInstance(s.modelConnection) ?? false,
mode: s.mode,
effort: s.effort,
busy: s.busy,
origin: "terminal",
})
}
/** What the hub would say to a terminal asking to open this session: undefined when it may. */
private releaseFor(peer: Peer, id: string): void {
const other = this.shared.get(id)
if (other && other.client !== peer && !other.client.isClosed) throw new RpcError(INVALID_PARAMS, `this session is open in another terminal (pid ${other.pid})`)
const held = this.agent?.holding(id)
if (held === "busy") throw new RpcError(INVALID_PARAMS, "this session is working on a prompt from the web UI — wait for it to finish, then open it")
if (held) this.agent!.drop(id)
}
private accept(peer: Peer) {
this.clients.add(peer)
let pid = 0
peer.handle("hub/hello", (p) => {
pid = Number(p?.pid) || 0
return { service: this.o.service, linked: Boolean(this.agent), pid: process.pid }
})
// Before a terminal resumes a session: the hub lets go of its own copy, or says why not.
peer.handle("hub/release", (p) => {
this.releaseFor(peer, String(p?.sessionId ?? ""))
return {}
})
peer.handle("hub/share", (p) => {
const id = String(p?.sessionId ?? "")
if (!id) throw new RpcError(INVALID_PARAMS, "no session")
this.releaseFor(peer, id)
const explicit = p?.explicit === true
const cwd = String(p?.cwd ?? "")
if (!explicit) {
if (!this.o.service) return { shared: false, reason: "no service runs here; /remote shares this session" }
const why = refusalFor(this.o.limits, cwd)
if (why) return { shared: false, reason: why }
}
const s: Shared = {
sessionId: id,
cwd,
workdir: typeof p?.workdir === "string" && p.workdir ? p.workdir : cwd,
title: String(p?.title ?? ""),
model: String(p?.model ?? ""),
...(typeof p?.modelConnection === "string" ? { modelConnection: p.modelConnection } : {}),
mode: (p?.mode ?? "manual") as Mode,
effort: String(p?.effort ?? ""),
explicit,
busy: p?.busy === true,
client: peer,
pid,
}
this.shared.set(id, s)
this.announce(s)
return { shared: true, linked: Boolean(this.agent) }
})
peer.handle("hub/unshare", (p) => {
this.unshare(peer, String(p?.sessionId ?? ""))
return {}
})
peer.on("hub/event", (p) => {
const s = this.shared.get(String(p?.sessionId ?? ""))
const e = p?.event
if (!s || s.client !== peer || !e || typeof e !== "object") return
if (e.type === "task") {
s.busy = e.state === "start"
if (typeof e.turnId === "string") {
if (e.state === "start") s.turnId = e.turnId
else {
s.lastTurnId = e.turnId
if (s.turnId === e.turnId) s.turnId = undefined
}
}
}
if (e.type === "mode") s.mode = e.mode
if (e.type === "model") {
s.model = String(e.ref ?? s.model)
if (typeof e.connection === "string") s.modelConnection = e.connection
s.effort = String(e.effort ?? s.effort)
}
if (e.type === "title") s.title = String(e.title ?? "")
this.agent?.emitShared(s.sessionId, e)
})
peer.handle("hub/ask", async (p) => {
const s = this.shared.get(String(p?.sessionId ?? ""))
if (!s || s.client !== peer) throw new RpcError(INVALID_PARAMS, "this session is not shared")
if (!this.agent) throw new RpcError(INVALID_PARAMS, "not linked")
return this.agent.askInstance(p.params)
})
// A terminal deleted a session: already let go of here (hub/release first), so the
// instance is told, and deletes the chat that is that session.
peer.on("hub/deleted", (p) => {
const id = String(p?.sessionId ?? "")
if (!id) return
const s = this.shared.get(id)
if (s && s.client === peer) this.shared.delete(id)
this.deleted(id)
})
peer.on("hub/settled", (p) => this.agent?.notifyInstance("_lembas/permission/settled", { sessionId: p?.sessionId, toolCallId: p?.toolCallId }))
// ask_user and plan_submit in a terminal's session: the same card in the web UI, the
// first answer wins — exactly as an approval.
for (const [method, card] of [
["hub/question", "_lembas/question"],
["hub/plan", "_lembas/plan"],
] as const)
peer.handle(method, async (p) => {
const s = this.shared.get(String(p?.sessionId ?? ""))
if (!s || s.client !== peer) throw new RpcError(INVALID_PARAMS, "this session is not shared")
if (!this.agent) throw new RpcError(INVALID_PARAMS, "not linked")
const answer: any = await this.agent.askInstanceCard(card, p.params)
// A plan approved from the web never puts a session shared by the device's limits (not by
// name) in a mode beyond remote.max_mode; where even manual is beyond it, nothing is decided.
if (card === "_lembas/plan" && answer?.outcome === "approve" && !s.explicit) {
const mode = clampMode(answer.mode === "manual" ? "manual" : "edit", this.o.limits.maxMode)
return mode === "edit" || mode === "manual" ? { ...answer, mode } : { outcome: "dismissed" }
}
return answer
})
// Its command set, at sharing and whenever it changes.
peer.on("hub/commands", (p) => {
const s = this.shared.get(String(p?.sessionId ?? ""))
if (!s || s.client !== peer || !Array.isArray(p?.commands)) return
s.commands = p.commands
this.agent?.commandsShared(s.sessionId, s.commands!)
})
peer.onClose(() => {
this.clients.delete(peer)
for (const s of [...this.shared.values()]) if (s.client === peer) this.unshare(peer, s.sessionId)
})
}
/** A session deleted on this machine, to the instance — or, with no link up now, kept for the
* next one (pending.ts): a delete made while the link is down is not lost, and its chat does
* not stay in the web UI for good. */
deleted(id: string) {
if (this.agent) this.agent.notifyDeleted(id)
else addPendingDeleted([id])
}
private unshare(peer: Peer, id: string) {
const s = this.shared.get(id)
if (!s || s.client !== peer) return
this.shared.delete(id)
// With the service, the session is still reachable: the next prompt from the web UI opens it
// here from the store. Without it, it is gone from the web UI's reach with the terminal.
this.agent?.notifyInstance("_lembas/session/left", { sessionId: id, available: this.o.service })
}
}
+172
View File
@@ -0,0 +1,172 @@
// The link: this machine dials out to the LLeMbas instance it is logged in to, and the web
// UI drives sessions here over ACP. Nothing listens on this machine — the connection is outbound,
// one per device, re-dialled with backoff when it drops — and what the instance may ask is bounded
// by this machine's own global `remote:` block (agent.ts: Limits), never by the server.
//
// One WebSocket message is one JSON-RPC message. The token goes in the Authorization header and
// never in the address. A token the instance refuses (revoked from its Settings → Security, or the
// account lost the permission) ends the link for good: retrying it would only knock on a closed door.
import { existsSync, readFileSync } from "node:fs"
import { hostname } from "node:os"
import { loadConfig } from "../config/load.ts"
import type { Mode } from "../config/schema.ts"
import { HARNESS_VERSION } from "../harness.ts"
import { instances, keyFile, rememberProtocols, type Instance } from "../lembas/login.ts"
import { discover, linkProtocol, spoken } from "../lembas/client.ts"
import { VERSION } from "../version.ts"
import { AcpAgent, type Limits } from "./agent.ts"
import type { Hub } from "./hub.ts"
import { Peer, type Transport } from "./rpc.ts"
export class LinkError extends Error {}
export function limitsFromConfig(): Limits & { enabled: boolean } {
const r = loadConfig().config.remote ?? {}
return {
enabled: r.enabled === true,
roots: r.roots ?? [],
maxMode: (r.max_mode ?? "edit") as Mode,
approvalTimeoutMs: (r.approval_timeout ?? 600) * 1000,
requireTrust: r.require_trust !== false,
terminal: r.terminal === true,
terminalIntegration: r.terminal_integration !== false,
}
}
export function pickInstance(name?: string): Instance {
const all = instances()
if (name) {
const one = all[name]
if (!one) throw new LinkError(`not logged in to ${name}${Object.keys(all).length ? ` — logged in: ${Object.keys(all).join(", ")}` : ""}`)
return one
}
const list = Object.values(all)
if (!list.length) throw new LinkError("not logged in to any LLeMbas instance — lembas login <address>")
if (list.length > 1) throw new LinkError(`logged in to several instances; name one: ${Object.keys(all).join(", ")}`)
return list[0]!
}
export function linkUrl(instance: Instance): string {
const u = new URL(instance.base_url)
u.protocol = u.protocol === "https:" ? "wss:" : "ws:"
u.pathname = "/api/devices/link"
return u.toString()
}
function wsTransport(ws: WebSocket): Transport {
let onMsg: (t: string) => void = () => {}
let onEnd: () => void = () => {}
ws.addEventListener("message", (e) => onMsg(typeof e.data === "string" ? e.data : new TextDecoder().decode(e.data as ArrayBuffer)))
ws.addEventListener("close", () => onEnd())
return { send: (t) => ws.send(t), onMessage: (fn) => (onMsg = fn), onClose: (fn) => (onEnd = fn), close: () => ws.close() }
}
export interface LinkStatus {
state: "connecting" | "linked" | "waiting" | "stopped"
instance: string
since: string
detail?: string
attempts: number
}
export interface LinkOptions {
instance?: string
/** Each change of state, for the service's status file and log. */
onStatus?: (s: LinkStatus) => void
/** Stop for good when this aborts. */
signal?: AbortSignal
/** Only for tests: the backoff's first wait and its ceiling. */
backoffMs?: [number, number]
/** The hub this link serves: the terminals' sessions reach the instance through it. */
hub?: Hub
/** A terminal's link for /remote: it runs whatever `remote.enabled` says; with remote
* work off, only the sessions the terminal shares can be reached. */
sharedOnly?: boolean
}
/** Dial, serve, and dial again, until stopped or refused. Resolves when it stops for good. */
export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
const configured = limitsFromConfig()
const limits = o.sharedOnly && !configured.enabled ? { ...configured, roots: [], sharedOnly: true } : configured
if (!o.sharedOnly && !limits.enabled) throw new LinkError("remote work is off on this machine — set remote.enabled: true (and remote.roots) in ~/.config/lembas/config.yaml")
if (!o.sharedOnly && !limits.roots.length) throw new LinkError("remote.roots is empty, so no directory could be worked in — list the directories a remote session may use")
const instance = pickInstance(o.instance)
const token = readFileSync(keyFile(instance.connection), "utf8").trim()
// A service runs for weeks: the webui connections' models are read again every ten minutes, so a
// session the web UI opens here starts with the instance's models as they are then.
const freshen = setInterval(() => void import("../lembas/webui.ts").then((w) => w.refreshWebui()), 10 * 60_000)
freshen.unref?.()
const [first, ceiling] = o.backoffMs ?? [1000, 60_000]
let wait = first
let attempts = 0
const status = (state: LinkStatus["state"], detail?: string): LinkStatus => {
const s = { state, instance: instance.base_url, since: new Date().toISOString(), detail, attempts }
o.onStatus?.(s)
return s
}
const ca = instance.ca && existsSync(instance.ca) ? readFileSync(instance.ca, "utf8") : undefined
/** A 4400 to a hello that said 2: the protocols known were stale (or discovery said more than the
* link takes) — the next dial, at once, says 1; only a 4400 to that stops the link. */
let downgraded = false
while (!o.signal?.aborted) {
attempts++
status("connecting")
// What the instance speaks now: asked at every dial, since it may have been updated
// since the login; what was said last when it cannot be asked.
let protocols = instances()[instance.connection]?.protocols ?? instance.protocols
try {
const { discovery } = await discover(instance.base_url, ca ? { ca: instance.ca } : undefined)
protocols = spoken(discovery)
rememberProtocols(instance.connection, protocols)
} catch {}
const protocol = downgraded ? 1 : linkProtocol(protocols)
const outcome = await new Promise<{ refused?: string; dropped?: string; final?: boolean }>((resolve) => {
let opened = false
const ws = new WebSocket(linkUrl(instance), {
headers: { authorization: `Bearer ${token}`, "user-agent": `lembas-cli/${VERSION}` },
...(ca ? { tls: { ca } } : {}),
} as unknown as string[])
const stop = () => ws.close()
o.signal?.addEventListener("abort", stop, { once: true })
ws.addEventListener("open", () => {
opened = true
wait = first
const peer = new Peer(wsTransport(ws))
new AcpAgent(peer, limits, instance.connection, o.hub, protocol)
peer.notify("_lembas/hello", {
protocol,
version: VERSION,
harness_spec: HARNESS_VERSION,
device: { host: hostname(), platform: `${process.platform}-${process.arch}` },
limits: { roots: limits.roots, max_mode: limits.maxMode, require_trust: limits.requireTrust },
})
status("linked")
})
ws.addEventListener("close", (e) => {
o.signal?.removeEventListener("abort", stop)
// 4401/4403: the instance's word that this token is done; 1008 policy, likewise.
if ([4401, 4403, 1008].includes(e.code)) return resolve({ refused: e.reason || "the instance refused this device's token" })
// 4400: the hello's protocol is not one it speaks. Dialling again would say the same.
if (e.code === 4400) return resolve({ refused: "the instance and this CLI speak different link protocols — update one of them", final: true })
resolve({ dropped: opened ? `the link dropped (${e.code}${e.reason ? `: ${e.reason}` : ""})` : "could not reach the instance" })
})
ws.addEventListener("error", () => {})
})
if (o.signal?.aborted) break
if (outcome.final && protocol === 2 && !downgraded) {
downgraded = true
status("waiting", "the instance does not take link protocol 2; trying again with 1")
continue
}
if (outcome.refused) {
clearInterval(freshen)
return status("stopped", outcome.final ? outcome.refused : `${outcome.refused} — sign in again: lembas login`)
}
status("waiting", `${outcome.dropped}; trying again in ${Math.round(wait / 1000)}s`)
await new Promise((r) => setTimeout(r, wait))
wait = Math.min(ceiling, wait * 2)
}
clearInterval(freshen)
return status("stopped", "stopped")
}
+61
View File
@@ -0,0 +1,61 @@
// Deletes the instance has not heard about yet.
//
// A session deleted on this machine is deleted in the web UI too: the terminal tells the hub
// (`hub/deleted`), and the hub tells the instance (`_lembas/session/deleted`) over the link. Any of
// those steps may find the next one missing — a terminal with no hub (no service, no /remote), a
// hub with no link up (the instance down, the network out), `lembas sessions delete` with neither
// — and the chat would stay in the web UI, pointing at a session that no longer exists. So whoever
// cannot pass the word on writes it here, and the service sends everything here once the next
// link has initialized, then clears it.
//
// A plain JSON list of session ids in the state directory, beside the hub's socket. Written whole
// through a rename, so a reader never sees half of it; deduplicated; the newest DELETED_MAX kept —
// a machine whose link never comes back must not grow it for ever, and a chat that old is long
// out of anybody's mind.
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
export const DELETED_MAX = 500
export const deletedFile = () => join(paths.state, "deleted.json")
/** The session ids waiting to be told to the instance, oldest first. */
export function pendingDeleted(): string[] {
try {
const v = JSON.parse(readFileSync(deletedFile(), "utf8"))
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string" && x.length > 0) : []
} catch {
return []
}
}
function write(ids: string[]) {
const file = deletedFile()
if (!ids.length) return void rmSync(file, { force: true })
mkdirSync(paths.state, { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
writeFileSync(tmp, JSON.stringify(ids), { mode: 0o600 })
renameSync(tmp, file)
}
/** Remember deletes to tell the instance later. Never in the way of the delete itself: a state
* directory that cannot be written to only loses the word. */
export function addPendingDeleted(ids: string[]) {
const add = ids.filter(Boolean)
if (!add.length) return
try {
// An id already waiting moves to the end: it is the newest word about it.
const list = [...pendingDeleted().filter((x) => !add.includes(x)), ...new Set(add)]
write(list.slice(-DELETED_MAX))
} catch {}
}
/** Forget the ones that were told. Re-read first, so an id another process added meanwhile (a
* terminal, `lembas sessions delete`) is kept for the next link. */
export function clearPendingDeleted(sent: string[]) {
if (!sent.length || !existsSync(deletedFile())) return
try {
write(pendingDeleted().filter((x) => !sent.includes(x)))
} catch {}
}
+187
View File
@@ -0,0 +1,187 @@
// A prompt that came over ACP, made into what the TUI would have sent for the same thing typed
//: `/name` expanded by the shared command set (session/commands.ts), `@path` mentions
// attached by the same expander as the TUI's (project/attach.ts, same limits), and the blocks a web
// composer adds — images and files — stored the way the TUI stores a pasted image or a dropped
// file: written under the session's attachment directory and attached by path, so a vision model
// sees the image, any other model is told it is there, and the model can read a file again later.
import { mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"
import { basename, extname, join, resolve } from "node:path"
import type { App } from "../app.ts"
import type { AttachmentInfo } from "../bus/index.ts"
import { paths } from "../config/paths.ts"
import type { Mode } from "../config/schema.ts"
import { attachmentsFor } from "../project/attach.ts"
import { realPath } from "../permission/evaluate.ts"
import { protectedPath } from "../permission/hardline.ts"
import { expandHome } from "../config/paths.ts"
import { IMAGE_TYPES } from "../project/image.ts"
import type { ImagePart } from "../provider/types.ts"
import { expandSlash } from "../session/commands.ts"
import { INVALID_PARAMS, RpcError } from "./rpc.ts"
/** Per block, and for the whole prompt, in bytes as decoded. */
export const BLOCK_LIMIT = 10 * 1024 * 1024
export const PROMPT_LIMIT = 25 * 1024 * 1024
const IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif"]
/** Where a session's attachments are kept. */
export const attachmentDir = (sessionId: string) => join(paths.state, "attachments", sessionId.replace(/[^\w.-]/g, "_"))
export function dropAttachments(sessionId: string) {
rmSync(attachmentDir(sessionId), { recursive: true, force: true })
}
/** A prompt's blocks, read: the text typed, and the files that came with it (not written yet). */
export interface Blocks {
text: string
files: { name: string; mimeType: string; data: Buffer }[]
}
function decoded(b64: unknown, what: string): Buffer {
if (typeof b64 !== "string") throw new RpcError(INVALID_PARAMS, `${what} has no data`)
// The size is known before decoding: base64 is 4 characters for 3 bytes.
if (b64.length * 0.75 > BLOCK_LIMIT + 3) throw new RpcError(INVALID_PARAMS, `${what} is larger than ${BLOCK_LIMIT / 1024 / 1024} MB; send a smaller one`)
return Buffer.from(b64, "base64")
}
const EXT: Record<string, string> = Object.fromEntries(Object.entries(IMAGE_TYPES).map(([ext, mime]) => [mime, ext]))
function safeName(name: string, fallback: string): string {
const n = basename(name || "").replace(/[^\w.+-]/g, "_").replace(/^\.+/, "").slice(0, 80)
return n || fallback
}
function nameOfUri(uri: unknown): string {
if (typeof uri !== "string" || !uri) return ""
try {
return decodeURIComponent(new URL(uri, "file:///").pathname.split("/").pop() ?? "")
} catch {
return uri.split("/").pop() ?? ""
}
}
/** The blocks of `session/prompt`: text, image, resource (text or blob), resource_link. */
export function readBlocks(blocks: unknown): Blocks {
if (!Array.isArray(blocks)) throw new RpcError(INVALID_PARAMS, "prompt must be an array of content blocks")
const parts: string[] = []
const files: Blocks["files"] = []
let total = 0
const add = (f: Blocks["files"][number]) => {
if (f.data.length > BLOCK_LIMIT) throw new RpcError(INVALID_PARAMS, `${f.name} is larger than ${BLOCK_LIMIT / 1024 / 1024} MB; send a smaller one`)
total += f.data.length
if (total > PROMPT_LIMIT) throw new RpcError(INVALID_PARAMS, `the attachments come to more than ${PROMPT_LIMIT / 1024 / 1024} MB together; send fewer at a time`)
files.push(f)
}
for (const b of blocks as any[]) {
if (b?.type === "text" && typeof b.text === "string") parts.push(b.text)
else if (b?.type === "image") {
const mime = String(b.mimeType ?? "")
if (!IMAGE_MIMES.includes(mime)) throw new RpcError(INVALID_PARAMS, `an image must be PNG, JPEG, WebP or GIF, not ${mime || "unnamed"}`)
const n = files.length + 1
const base = safeName(nameOfUri(b.uri), `image-${n}`)
add({ name: extname(base) ? base : `${base}${EXT[mime] ?? ".png"}`, mimeType: mime, data: decoded(b.data, "an image") })
} else if (b?.type === "resource" && b.resource && typeof b.resource === "object") {
const r = b.resource
const name = safeName(nameOfUri(r.uri), `file-${files.length + 1}${typeof r.text === "string" ? ".txt" : ""}`)
if (typeof r.text === "string") add({ name, mimeType: String(r.mimeType ?? "text/plain"), data: Buffer.from(r.text, "utf8") })
else if (r.blob !== undefined) add({ name, mimeType: String(r.mimeType ?? "application/octet-stream"), data: decoded(r.blob, name) })
} else if (b?.type === "resource_link" && typeof b.uri === "string") parts.push(`(see ${b.uri})`)
}
return { text: parts.join("\n\n").trim(), files }
}
/** What a prompt becomes. */
export type Prepared =
| { kind: "run"; prompt: string; shown: string; extra: (string | ImagePart)[]; attachments: AttachmentInfo[]; atPaths: string[]; model?: string; mode?: Mode; command?: string }
| { kind: "action"; name: "compact" | "undo" | "plan"; arg: string }
| { kind: "message"; name: string; text: string }
/** Whether an @path in a prompt from the web may be attached: where it really is — links
* followed — inside the session's project and inside the device's `remote.roots` (when given),
* and not a protected path. Anything else stays text: an @ is never a way out of the project. */
export function mayAttach(abs: string, root: string, roots?: string[]): boolean {
const real = realPath(abs)
const within = (p: string, dir: string) => {
const d = realPath(dir)
return p === d || p.startsWith(d.endsWith("/") ? d : d + "/")
}
if (!within(real, root)) return false
if (roots && !roots.some((r) => within(real, expandHome(r)))) return false
return !protectedPath(real) && !protectedPath(abs)
}
function mimeOf(path: string): string {
return IMAGE_TYPES[extname(path).toLowerCase()] ?? (/\.(md|txt|ts|tsx|js|py|json|ya?ml|toml|sh|css|html|rs|go|c|h|java)$/i.test(path) ? "text/plain" : "application/octet-stream")
}
/** The prompt, for `app`'s session `sessionId`: files written, the command expanded, @files read.
* Throws INVALID_PARAMS with a sentence for a prompt that cannot be sent. */
export function preparePrompt(app: App, sessionId: string, blocks: unknown, o: { roots?: string[] } = {}): Prepared {
const { text, files } = readBlocks(blocks)
if (!text && !files.length) throw new RpcError(INVALID_PARAMS, "the prompt has no text")
let cmd = expandSlash(app, text)
// A command that sends no prompt has nowhere to put the files: then the text is only text.
if (files.length && cmd && cmd.kind !== "prompt") cmd = undefined
if (cmd?.kind === "action" || cmd?.kind === "message") return cmd
const ctx = app.engine.o.toolCtx
const vision = app.engine.model.spec.vision === true
// The files written first, under names that stay apart from the ones before.
const saved: { abs: string; info: AttachmentInfo }[] = []
if (files.length) {
const dir = attachmentDir(sessionId)
mkdirSync(dir, { recursive: true, mode: 0o700 })
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..*$/, "")
for (const f of files) {
// A part of a uuid after the stamp: two uploads of one name (in one prompt, or one second) stay two.
const abs = resolve(dir, `${stamp}-${crypto.randomUUID().slice(0, 8)}-${f.name}`)
writeFileSync(abs, f.data, { mode: 0o600 })
saved.push({ abs, info: { name: f.name, mimeType: f.mimeType, size: f.data.length } })
}
}
const prompt = cmd?.kind === "prompt" ? cmd.prompt : text || `(attached: ${saved.map((s) => s.info.name).join(", ")})`
const shown = cmd?.kind === "prompt" ? cmd.shown : prompt
// @files only from what the person typed — never from a command's own text (Working-notes #18).
// And only what may be read from here: the web is a remote keyboard, not one at this machine.
const mentioned = attachmentsFor(cmd?.kind === "prompt" ? cmd.attachFrom : text, ctx, vision, (abs) => mayAttach(abs, app.project.root, o.roots))
const uploaded = saved.flatMap((s) => attachmentsFor(`@${s.abs}`, ctx, vision))
const extra = [...mentioned, ...uploaded].flatMap((a) => (a.image ? [a.text, a.image] : [a.text]))
const attachments: AttachmentInfo[] = [...mentioned.map((a) => mentionInfo(a.path, ctx.cwd)), ...saved.map((s) => s.info)]
return {
kind: "run",
prompt,
shown,
extra,
attachments,
atPaths: mentioned.map((a) => a.path),
...(cmd?.kind === "prompt" ? { command: cmd.name, ...(cmd.model ? { model: cmd.model } : {}), ...(cmd.mode ? { mode: cmd.mode } : {}) } : {}),
}
}
/** An @file, @directory or @image of a prompt, as a chip. */
export function mentionInfo(path: string, cwd: string): AttachmentInfo {
return { name: path, mimeType: path.endsWith("/") ? "inode/directory" : mimeOf(path), size: sizeOf(resolve(cwd, path)) }
}
function sizeOf(p: string): number {
try {
return statSync(p).size
} catch {
return 0
}
}
/** The attachments of a stored user message, from the parts the expander wrote (`<file path=…>`,
* `<image path=…>`, `<directory path=…>`), for `_lembas/session/history`. */
export function storedAttachments(texts: string[], cwd: string): AttachmentInfo[] {
const out: AttachmentInfo[] = []
for (const t of texts) {
const m = /^<(file|image|directory) path="([^"]*)"/.exec(t)
if (!m) continue
const path = m[2]!
const abs = resolve(cwd, path)
// An upload is stored under a name with its time in front; the chip shows the name it came with.
const name = abs.startsWith(join(paths.state, "attachments") + "/") ? basename(path).replace(/^\d{8}T\d{6}-(?:[0-9a-f]{8}-)?/, "") : path
out.push({ name, mimeType: m[1] === "directory" ? "inode/directory" : mimeOf(path), size: sizeOf(abs) })
}
return out
}
+162
View File
@@ -0,0 +1,162 @@
// JSON-RPC 2.0 over lines: the framing ACP uses on stdio, and what the LLeMbas link carries one
// message per WebSocket frame. Both ends may call and both may be called; a request waits for the
// answer with the same id. Written here rather than taken from the ACP SDK so the same few rules
// are what LLeMbas implements in Python, and nothing about them is hidden in a dependency.
export interface Transport {
send(text: string): void
onMessage(fn: (text: string) => void): void
onClose(fn: () => void): void
close(): void
}
export class RpcError extends Error {
constructor(
public code: number,
message: string,
public data?: unknown,
) {
super(message)
}
}
export const PARSE_ERROR = -32700
export const INVALID_REQUEST = -32600
export const METHOD_NOT_FOUND = -32601
export const INVALID_PARAMS = -32602
export const INTERNAL_ERROR = -32603
type Handler = (params: any) => unknown | Promise<unknown>
export class Peer {
private handlers = new Map<string, Handler>()
private notices = new Map<string, Handler>()
private waiting = new Map<number | string, { resolve: (v: any) => void; reject: (e: Error) => void }>()
private seq = 0
private closed = false
private closers: (() => void)[] = []
constructor(private t: Transport) {
t.onMessage((text) => void this.receive(text))
t.onClose(() => {
this.closed = true
for (const w of this.waiting.values()) w.reject(new RpcError(INTERNAL_ERROR, "the connection closed"))
this.waiting.clear()
for (const fn of this.closers) fn()
})
}
/** Answer `method` requests. */
handle(method: string, fn: Handler) {
this.handlers.set(method, fn)
}
/** React to `method` notifications. */
on(method: string, fn: Handler) {
this.notices.set(method, fn)
}
onClose(fn: () => void) {
this.closers.push(fn)
}
get isClosed() {
return this.closed
}
request<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T> {
if (this.closed) return Promise.reject(new RpcError(INTERNAL_ERROR, "the connection is closed"))
const id = ++this.seq
return new Promise<T>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined
if (timeoutMs)
timer = setTimeout(() => {
this.waiting.delete(id)
reject(new RpcError(INTERNAL_ERROR, `${method}: no answer in ${Math.round(timeoutMs / 1000)}s`))
}, timeoutMs)
this.waiting.set(id, {
resolve: (v) => (clearTimeout(timer), resolve(v)),
reject: (e) => (clearTimeout(timer), reject(e)),
})
this.write({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) })
})
}
notify(method: string, params?: unknown) {
if (this.closed) return
this.write({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) })
}
close() {
this.t.close()
}
private write(message: unknown) {
try {
this.t.send(JSON.stringify(message))
} catch {}
}
private async receive(text: string) {
let msg: any
try {
msg = JSON.parse(text)
} catch {
return this.write({ jsonrpc: "2.0", id: null, error: { code: PARSE_ERROR, message: "Parse error" } })
}
if (!msg || msg.jsonrpc !== "2.0") return
// An answer to one of ours.
if (msg.method === undefined && msg.id !== undefined) {
const w = this.waiting.get(msg.id)
if (!w) return
this.waiting.delete(msg.id)
if (msg.error) w.reject(new RpcError(msg.error.code ?? INTERNAL_ERROR, msg.error.message ?? "error", msg.error.data))
else w.resolve(msg.result)
return
}
const method = String(msg.method ?? "")
// A notification: nothing is answered, even an unknown one.
if (msg.id === undefined || msg.id === null) {
const fn = this.notices.get(method)
if (fn) {
try {
await fn(msg.params)
} catch {}
}
return
}
const fn = this.handlers.get(method)
if (!fn) return this.write({ jsonrpc: "2.0", id: msg.id, error: { code: METHOD_NOT_FOUND, message: `Method not found: ${method}` } })
try {
const result = await fn(msg.params ?? {})
this.write({ jsonrpc: "2.0", id: msg.id, result: result ?? null })
} catch (e) {
const err = e instanceof RpcError ? e : new RpcError(INTERNAL_ERROR, (e as Error).message)
this.write({ jsonrpc: "2.0", id: msg.id, error: { code: err.code, message: err.message, ...(err.data === undefined ? {} : { data: err.data }) } })
}
}
}
/** Newline-delimited JSON on a pair of streams: ACP over stdio. */
export function stdioTransport(input: NodeJS.ReadableStream = process.stdin, output: NodeJS.WritableStream = process.stdout): Transport {
let onMsg: (t: string) => void = () => {}
let onEnd: () => void = () => {}
let buf = ""
input.setEncoding?.("utf8")
input.on("data", (chunk: string | Buffer) => {
buf += chunk.toString()
let nl: number
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim()
buf = buf.slice(nl + 1)
if (line) onMsg(line)
}
})
input.on("end", () => onEnd())
return {
send: (text) => void output.write(text + "\n"),
onMessage: (fn) => (onMsg = fn),
onClose: (fn) => (onEnd = fn),
close: () => onEnd(),
}
}
+560
View File
@@ -0,0 +1,560 @@
// A terminal's side of the hub (hub.ts): its session shown and used in the web UI too.
//
// With the service running, the session the terminal has open is shared by itself — within the
// device's `remote:` limits — and follows /new and /sessions. /remote shares it by name: through
// the service when it runs, otherwise by holding the link here, for as long as this terminal runs.
//
// Shared, the web UI is a second keyboard on the same session: its prompt runs here as if typed,
// its message mid-task joins the task, its stop stops it, and an approval is asked on both sides —
// whichever answers first decides, and the other side's card goes away.
import type { App } from "../app.ts"
import type { AskReply, Asker, AttachmentInfo, Event } from "../bus/index.ts"
import { asMode, type Mode } from "../config/schema.ts"
import type { ImagePart } from "../provider/types.ts"
import { commandSet, sourcesOf } from "../session/commands.ts"
import type { PlanReply } from "../tool/plan_exit.ts"
import type { QuestionReply, QuestionRequest } from "../tool/question.ts"
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
import { dropAttachments, preparePrompt } from "./prompt.ts"
import { connectHub, Hub } from "./hub.ts"
import { addPendingDeleted } from "./pending.ts"
import { REMOTE_META, type Store } from "../session/store.ts"
import { limitsFromConfig, runLink } from "./link.ts"
import { INVALID_PARAMS, RpcError, type Peer } from "./rpc.ts"
type AskRequest = Parameters<Asker["ask"]>[0]
/** An approval shown here, which the web UI's answer can take down. */
export interface LocalAsk<R = AskReply> {
reply: Promise<R>
dismiss(r: R): void
}
/** A prompt from the web UI, made into what the TUI sends: the expanded text, what the
* transcript shows, the attachments for the model and as chips, its turn id, and a command's model
* and mode for this one prompt. */
export interface SharedPrompt {
prompt: string
shown: string
extra: (string | ImagePart)[]
attachments: AttachmentInfo[]
turnId: string
model?: string
mode?: Mode
}
export interface ShareHooks {
/** A prompt from the web UI: run it as if typed here, calling `started` when it stops waiting for
* the turn before it. Resolves with the engine's stop reason. */
prompt(input: SharedPrompt, started: () => void): Promise<string>
/** A command from the web UI that sends no prompt — compact, undo, plan; absent, the
* share does it on the app and says `changed`. */
action?(name: "compact" | "undo" | "plan", arg: string): Promise<Record<string, unknown>>
notice(text: string, level?: "info" | "warn"): void
/** /compact, asked from the web UI. Resolves with the summary. */
compact(): Promise<string>
/** The web UI deleted this session: it is gone here too, and a new one starts. */
deleted(): void
/** Shared or not, mode, model or effort changed: redraw. */
changed(): void
}
let current: Share | undefined
/** Before a session is resumed in a terminal: the hub lets go of its own copy of it, or says why it
* cannot (working on a prompt from the web UI, or open in another terminal). */
export async function releaseForResume(id: string): Promise<string | undefined> {
return current ? current.release(id) : releaseVia(undefined, id)
}
async function releaseVia(hub: Peer | undefined, id: string): Promise<string | undefined> {
const peer = hub ?? (await connectHub())
if (!peer) return undefined
try {
await peer.request("hub/release", { sessionId: id }, 5000)
return undefined
} catch (e) {
return (e as Error).message
} finally {
if (peer !== hub) peer.close()
}
}
/** Delete a stored session here and in the web UI (from Share.deleted): the hub lets go of
* its copy first (or says why it cannot — working on a prompt from the web UI, open in another
* terminal), the rows and what hangs off them go, and the hub is told so that the instance deletes
* the chat. `hub`: a connection to it already open (a terminal's); without one, one is made for
* this delete when a hub runs — `lembas sessions delete` reaches it the way a terminal does. With
* no hub at all, the word waits in the pending list for the service's next link. */
export async function deleteEverywhere(store: Store, id: string, hub?: Peer): Promise<string | undefined> {
const peer = hub ?? (await connectHub())
try {
if (peer) {
try {
await peer.request("hub/release", { sessionId: id }, 5000)
} catch (e) {
return (e as Error).message
}
}
store.deleteSession(id)
dropAttachments(id)
sessionTurnFile(id).drop()
if (!peer) return void addPendingDeleted([id])
peer.notify("hub/deleted", { sessionId: id })
// A connection made for this delete closes next; a request after the notification is answered
// only once the hub has read the notification, so the word is not lost with the socket.
if (peer !== hub) await peer.request("hub/hello", { pid: process.pid }, 5000).catch(() => {})
return undefined
} finally {
if (peer && peer !== hub) peer.close()
}
}
/** The session a terminal leaves, deleted when nothing was ever said in it: every start
* makes one, and a TUI opened and closed again left it behind as an empty row in /sessions and,
* shared, an empty chat in the web UI. The same rule as a resume's discard (app.ts): no message
* rows at all, and the instance told only when it was shown the session (REMOTE_META). Kept when a
* running hub will not let go of it — open in another terminal, or the service working in it.
* Called after the terminal's own link to the hub is closed, so that link no longer counts. */
export async function discardOnExit(store: Store, id: string | undefined): Promise<boolean> {
if (!id || !store.session(id) || !store.isEmpty(id)) return false
const peer = await connectHub()
try {
if (peer) {
try {
await peer.request("hub/release", { sessionId: id }, 5000)
} catch {
return false
}
}
// Asked again after the wait: a prompt from the web UI could have landed meanwhile.
if (!store.isEmpty(id)) return false
const shown = store.meta<boolean>(id, REMOTE_META) === true
if (!store.deleteSession(id)) return false
dropAttachments(id)
sessionTurnFile(id).drop()
if (shown) {
if (!peer) addPendingDeleted([id])
else {
peer.notify("hub/deleted", { sessionId: id })
await peer.request("hub/hello", { pid: process.pid }, 5000).catch(() => {})
}
}
return true
} finally {
peer?.close()
}
}
export class Share {
private hub?: Peer
/** The hub is the service: every session here is shared by itself. */
private service = false
/** The link this terminal holds for /remote, when nobody else does. */
private own?: { hub: Hub; stop: AbortController }
/** The session shared now. */
sharedId?: string
/** Shared by name (/remote): it stays shared across a dropped hub, and the limits do not decide. */
explicit = false
private retry?: ReturnType<typeof setInterval>
private off: () => void
private closed = false
/** The shared session's turns, by id: a prompt sent twice runs once — on the file the
* service uses too, so a turn run here is not run again there after the session moves. */
private turnLog?: { id: string; log: TurnLog<PromptResult> }
private get turns(): TurnLog<PromptResult> {
const id = this.app.engine.sessionId ?? ""
if (this.turnLog?.id !== id) this.turnLog = { id, log: sessionTurns<PromptResult>(id) }
return this.turnLog.log
}
/** The command set last sent to the hub. */
private commandsSent?: string
constructor(
private app: App,
private hooks: ShareHooks,
) {
this.off = app.bus.on((e) => this.onEvent(e))
current = this
}
/** Find the hub, and keep looking for one (the service may start later). */
async start() {
await this.connect()
this.retry = setInterval(() => void this.reconnect(), 10_000)
this.retry.unref?.()
}
/** The status bar's word: shared, and how. */
label(): string {
if (!this.sharedId) return ""
return this.own ? "⇄ web (this terminal)" : "⇄ web"
}
status(): string {
if (this.sharedId) return `this session is shared with the web UI ${this.own ? "— the link is held by this terminal, so it lasts as long as the terminal does" : "through the service"}`
if (this.hub && this.service) return "not shared: this session is outside what the service shares (remote.roots, trust) — /remote shares it anyway"
if (this.hub) return "not shared — /remote shares it"
return "not shared, and no service runs here — /remote shares it for as long as this terminal runs"
}
release(id: string) {
return releaseVia(this.hub, id)
}
/** A session deleted here (/sessions, /delete): the hub lets go of its copy, and the web UI
* deletes the chat it is. Returns why not, when it cannot be (working in the web UI). */
async deleted(id: string): Promise<string | undefined> {
if (!this.app.store) return undefined
return deleteEverywhere(this.app.store, id, this.hub)
}
/** /remote on or off. Returns what to say. */
async remote(on: boolean): Promise<string> {
if (!on) {
const id = this.sharedId
this.explicit = false
if (!id) return "this session is not shared"
this.sharedId = undefined
await this.hub?.request("hub/unshare", { sessionId: id }, 5000).catch(() => {})
if (this.own) this.dropOwn()
this.hooks.changed()
return "no longer shared with the web UI"
}
if (!this.hub) {
const why = await this.becomeHub()
if (why) return why
}
await this.share(true)
return this.sharedId ? this.status() : "could not share this session"
}
/** An approval of the shared session goes to both sides; the first answer counts. */
async ask(req: AskRequest, local: LocalAsk): Promise<AskReply> {
const id = this.sharedId
const hub = this.hub
if (!id || !hub || this.app.engine.sessionId !== id) return local.reply
const params = permissionParams(id, req, { patient: true })
// A web UI that cannot show it (or a link that drops) never answers: the terminal's card does.
const remote = hub.request("hub/ask", { sessionId: id, params }).then(
(a) => replyFrom(a, !req.request.alwaysAsk),
() => new Promise<never>(() => {}),
)
const won = await Promise.race([local.reply.then((r) => ({ r, here: true })), remote.then((r) => ({ r, here: false }))])
if (won.here) hub.notify("hub/settled", { sessionId: id, toolCallId: params.toolCall.toolCallId })
else {
local.dismiss(won.r)
this.hooks.notice(`answered in the web UI: ${won.r.kind === "deny" ? "denied" : "allowed"}`)
}
return won.r
}
/** ask_user in the shared session: the card here and in the web UI, the first answer wins. */
question(req: QuestionRequest, callId: string | undefined, local: LocalAsk<QuestionReply>): Promise<QuestionReply> {
return this.card(local, "hub/question", (id) => questionParams(id, req, callId, { patient: true }), (a) => questionReplyFrom(a, req), (r) => (r.dismissed ? "dismissed" : "answered"))
}
/** plan_submit in the shared session, likewise. */
plan(req: { path: string; text: string }, callId: string | undefined, local: LocalAsk<PlanReply>): Promise<PlanReply> {
return this.card(local, "hub/plan", (id) => planParams(id, req, callId, { patient: true }), (a) => planReplyFrom(a), (r) => (r.kind === "approve" ? `approved (${r.mode})` : r.kind === "revise" ? "to be revised" : "not decided"))
}
private async card<R>(local: LocalAsk<R>, method: string, params: (sessionId: string) => { toolCallId: string }, read: (answer: unknown) => R, said: (r: R) => string): Promise<R> {
const id = this.sharedId
const hub = this.hub
if (!id || !hub || this.app.engine.sessionId !== id) return local.reply
const p = params(id)
// A web UI that cannot show it (or a link that drops) never answers: the terminal's card does.
const remote = hub.request(method, { sessionId: id, params: p }).then(read, () => new Promise<never>(() => {}))
const won = await Promise.race([local.reply.then((r) => ({ r, here: true })), remote.then((r) => ({ r, here: false }))])
if (won.here) hub.notify("hub/settled", { sessionId: id, toolCallId: p.toolCallId })
else {
local.dismiss(won.r)
this.hooks.notice(`answered in the web UI: ${said(won.r)}`)
}
return won.r
}
/** The command set, to the hub when it changed. */
private sendCommands(always = false) {
const id = this.sharedId
if (!id || !this.hub) return
let list
try {
list = commandSet(sourcesOf(this.app))
} catch {
return
}
const key = JSON.stringify(list)
if (!always && key === this.commandsSent) return
this.commandsSent = key
this.hub.notify("hub/commands", { sessionId: id, commands: list })
}
close() {
this.closed = true
if (current === this) current = undefined
clearInterval(this.retry)
this.off()
// Closing the connection is the hub's word that every session of this terminal is gone.
this.hub?.close()
this.dropOwn()
}
private dropOwn() {
if (!this.own) return
this.own.stop.abort()
this.own.hub.close()
this.own = undefined
}
private async connect(): Promise<boolean> {
const peer = await connectHub()
if (!peer) return false
try {
const hello = await peer.request<{ service?: boolean }>("hub/hello", { pid: process.pid }, 5000)
this.hub = peer
this.service = hello?.service === true
this.wire(peer)
peer.onClose(() => {
if (this.hub !== peer) return
this.hub = undefined
const was = this.sharedId
this.sharedId = undefined
if (was && !this.closed) this.hooks.notice("the link to the web UI went away — the session goes on here", "warn")
this.hooks.changed()
})
await this.share(this.explicit)
return true
} catch {
peer.close()
return false
}
}
private async reconnect() {
if (this.closed || this.hub) return
if (await this.connect()) return
// Shared by name, and nobody holds the link any more: hold it here.
if (this.explicit) await this.becomeHub()
}
/** Hold the link in this terminal, for /remote while no service runs. Returns why not, if not. */
private async becomeHub(): Promise<string | undefined> {
const { instances } = await import("../lembas/login.ts")
if (!Object.keys(instances()).length) return "not logged in to a LLeMbas instance — /login first"
const hub = new Hub({ service: false, limits: limitsFromConfig() })
if (!(await hub.listen())) return (await this.connect()) ? undefined : "something holds the hub's socket but does not answer"
const stop = new AbortController()
this.own = { hub, stop }
void runLink({
hub,
sharedOnly: true,
signal: stop.signal,
onStatus: (s) => {
if (s.state === "stopped" && s.detail !== "stopped") this.hooks.notice(`the link to the web UI stopped: ${s.detail}`, "warn")
},
}).catch((e) => {
this.hooks.notice(`/remote: ${(e as Error).message}`, "warn")
this.dropOwn()
})
return (await this.connect()) ? undefined : "could not reach the hub this terminal started"
}
private async share(explicit: boolean) {
const hub = this.hub
const id = this.app.engine.sessionId
if (!hub || !id) return
try {
const r = await hub.request<{ shared?: boolean; reason?: string }>(
"hub/share",
{
sessionId: id,
cwd: this.app.project.root,
// Where it works, which @path mentions are read from: a session started in a
// subdirectory is not at its project's root.
workdir: this.app.engine.o.toolCtx.cwd,
title: this.app.store?.session(id)?.title ?? "",
model: this.app.engine.model.ref,
// Which connection that model is spoken to through, so the hub can say whether it
// is the instance's.
modelConnection: this.app.engine.model.connectionName,
mode: this.app.engine.mode,
effort: this.app.engine.effort ?? "off",
busy: Boolean(this.app.turns.current),
explicit,
},
10_000,
)
if (r?.shared) {
// In the web UI's hands from now on: deletable from there even once this terminal has let
// go of it, wherever it is (agent.ts, deletableFor).
markRemote(this.app.store, id)
this.sharedId = id
this.explicit = explicit
this.sendCommands(true)
} else {
this.sharedId = undefined
if (explicit) this.hooks.notice(`not shared: ${r?.reason ?? "the hub said no"}`, "warn")
}
} catch (e) {
this.sharedId = undefined
if (explicit) this.hooks.notice(`not shared: ${(e as Error).message}`, "warn")
}
this.hooks.changed()
}
/** compact, undo or plan sent from the web UI: the TUI's own way when it has one. */
private async action(name: "compact" | "undo" | "plan", arg: string): Promise<Record<string, unknown>> {
if (this.hooks.action) return this.hooks.action(name, arg)
if (name === "compact") return { summary: await this.hooks.compact() }
if (name === "undo") {
const r = this.app.turns.undo()
this.hooks.changed()
return { undone: r ?? null }
}
this.app.engine.mode = "plan"
this.app.bus.emit({ type: "mode", mode: "plan" })
this.hooks.changed()
return { mode: "plan" }
}
private onEvent(e: Event) {
// The empty session a resume left (app.ts): gone from the store already; the web UI's
// chat for it goes too, where it was shown one — through the hub, or the pending list.
if (e.type === "discarded") {
dropAttachments(e.id)
sessionTurnFile(e.id).drop()
if (!e.shown) return
if (this.hub) this.hub.notify("hub/deleted", { sessionId: e.id })
else addPendingDeleted([e.id])
return
}
// Every turn here, typed or sent from the web UI, by its id.
if (e.type === "task" && e.turnId) e.state === "start" ? this.turns.started(e.turnId) : this.turns.ended(e.turnId)
if (e.type === "session") {
if (e.id === this.sharedId) return
// /new or /sessions: the old one stops being shared, and the new one is shared as any is.
const was = this.sharedId
this.sharedId = undefined
this.explicit = false
if (was) void this.hub?.request("hub/unshare", { sessionId: was }, 5000).catch(() => {})
void this.share(false)
return
}
if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) {
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e })
if (e.type === "task" && e.state === "end") this.sendCommands()
}
}
private mine(p: any) {
if (!this.sharedId || p?.sessionId !== this.sharedId) throw new RpcError(INVALID_PARAMS, "that session is not open in this terminal any more")
}
/** What the web UI asks of the session, done here as if from the keyboard. */
private wire(peer: Peer) {
peer.handle("session/prompt", async (p): Promise<PromptResult> => {
this.mine(p)
const turnId = typeof p?.turnId === "string" && p.turnId ? p.turnId : crypto.randomUUID()
// A turn id taken already: the original's answer.
if (this.turns.has(turnId)) return this.turns.run(turnId, () => Promise.reject(new Error("unreachable")))
// The blocks, expanded here as this terminal's keyboard would; `text` from a hub of
// an earlier build.
const blocks = Array.isArray(p?.prompt) ? p.prompt : [{ type: "text", text: String(p?.text ?? "") }]
// The device's limits for a prompt from the web: an @path only inside this session's project
// and the remote roots (where any are set).
const limits = limitsFromConfig()
const roots = limits.roots
const prepared = preparePrompt(this.app, this.sharedId!, blocks, { roots: roots.length || !this.explicit ? roots : undefined })
return this.turns.run(turnId, async (started) => {
const done = (meta: Record<string, unknown>): PromptResult => ({ stopReason: "end_turn", _meta: { lembas: { turnId, ...meta } } })
if (prepared.kind === "message") {
started()
this.hooks.notice(`/${prepared.name} (from the web UI): ${prepared.text}`)
return done({ command: prepared.name, message: prepared.text })
}
if (prepared.kind === "action") {
started()
return done({ command: prepared.name, ...(await this.action(prepared.name, prepared.arg)) })
}
const { kind: _, atPaths: __, command, ...input } = prepared
// A command's mode from the web never goes past remote.max_mode in a session shared under
// the device's limits (not by name), as in a session the service runs.
if (input.mode && !this.explicit) input.mode = clampMode(input.mode, limits.maxMode)
const reason = await this.hooks.prompt({ ...input, turnId }, started)
return { stopReason: STOP[reason] ?? "end_turn", _meta: { lembas: { turnId, ...(command ? { command } : {}) } } }
})
})
// The terminal's turns: `_lembas/session/status` for a shared session asks here.
peer.handle("hub/status", (p) => {
this.mine(p)
return { ...(this.turns.running ? { turnId: this.turns.running } : {}), queued: this.turns.queued(), ...(this.turns.last ? { lastTurnId: this.turns.last } : {}) }
})
peer.handle("hub/steer", (p) => {
this.mine(p)
const id = typeof p?.messageId === "string" && p.messageId ? p.messageId : undefined
if (!this.app.turns.steer(String(p?.text ?? ""), id)) throw new RpcError(INVALID_PARAMS, "this session is not working on anything; send it as a prompt")
return { queued: this.app.engine.queued }
})
peer.handle("hub/compact", async (p) => {
this.mine(p)
if (this.app.turns.current) throw new RpcError(INVALID_PARAMS, "this session is working — compact it when the reply is done")
try {
return { summary: await this.hooks.compact() }
} catch (e) {
throw new RpcError(INVALID_PARAMS, `compaction failed: ${(e as Error).message}`)
}
})
peer.handle("hub/title", (p) => {
this.mine(p)
this.app.rename(String(p?.title ?? ""))
return { title: String(p?.title ?? "").trim() }
})
// The web UI deleted the chat this session is: stopped, gone from the store, and the terminal
// goes on in a new session (which is shared as any is).
peer.handle("hub/delete", async (p) => {
this.mine(p)
const id = this.sharedId!
this.app.cancel()
// Stopped first, as the agent's own delete waits for its queue: until then the
// cancelled turn could still be writing its last rows — into a session about to be gone, or,
// once `deleted` below has moved the terminal on, into the new one. Bounded: a turn that does
// not stop in time is deleted under it, as before.
for (let i = 0; i < 100 && this.app.turns.current; i++) await Bun.sleep(100)
// Moved on meanwhile (/new, /sessions): the session is no longer this terminal's to replace.
const still = this.sharedId === id
if (still) {
this.sharedId = undefined
this.explicit = false
this.hooks.deleted()
}
dropAttachments(id)
sessionTurnFile(id).drop()
return { deleted: this.app.store?.deleteSession(id) ?? false }
})
peer.on("hub/cancel", (p) => {
if (this.sharedId && p?.sessionId === this.sharedId) this.app.cancel()
})
peer.handle("hub/mode", (p) => {
this.mine(p)
const mode = asMode(p?.mode)
if (!mode) throw new RpcError(INVALID_PARAMS, `unknown mode ${p?.mode}`)
this.app.engine.mode = mode
this.app.bus.emit({ type: "mode", mode })
this.hooks.changed()
return {}
})
peer.handle("hub/configure", (p) => {
this.mine(p)
// Compared as the ref the model has now: the instance's `<login>/<served>` names the
// 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)
const effort = effortOf(p?.effort)
if (effort !== undefined) this.app.engine.effort = effort === "off" ? null : effort
this.hooks.changed()
return { model: this.app.engine.model.ref, effort: this.app.engine.effort ?? "off" }
})
}
}
+202
View File
@@ -0,0 +1,202 @@
// Shell integration for a terminal opened from the web UI: the shell marks where each
// prompt, command and its output start and how the command ended (OSC 133 A/B/C/D), and says its
// working directory after every command (OSC 7). The web UI's terminal reads those marks — the
// directory in the panel's header, a green or red mark beside each finished command, jumping
// between commands, copying the last one's output — and nothing on the server parses anything.
//
// The user's own startup files run first, exactly as for `$SHELL -l`, and the marks go in after
// them; nothing else in their environment changes. bash gets an rc file in place of its login
// files (which it sources), zsh a ZDOTDIR whose files source the user's own and hand ZDOTDIR back,
// fish a command run after its config. Any other shell starts as it did before, without marks.
// `remote.terminal_integration: false` turns it off.
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { basename, join } from "node:path"
import { paths } from "../config/paths.ts"
export type ShellKind = "bash" | "zsh" | "fish" | "other"
export function shellKind(shell: string): ShellKind {
const b = basename(shell)
return b === "bash" || b === "zsh" || b === "fish" ? b : "other"
}
const BASH = `# LLeMbas CLI: shell integration for a terminal opened from the web UI. Generated; deleted when
# the terminal closes.
#
# bash reads this in place of ~/.bashrc (--rcfile), after /etc/bash.bashrc, so it does what a login
# shell would: your profile files, in a login shell's order, then logout and ~/.bash_logout as a
# login shell has them. /etc/profile reads /etc/bash.bashrc again when PS1 is set (Debian's does),
# so it is read with PS1 unset: once is enough.
__lembas_ps1=$PS1
unset PS1
[ -r /etc/profile ] && . /etc/profile
PS1=$__lembas_ps1
unset __lembas_ps1
if [ -r ~/.bash_profile ]; then . ~/.bash_profile
elif [ -r ~/.bash_login ]; then . ~/.bash_login
elif [ -r ~/.profile ]; then . ~/.profile
fi
logout() { exit "$@"; }
[ -z "$(trap -p EXIT)" ] && trap '[ -r ~/.bash_logout ] && . ~/.bash_logout' EXIT
# Then the marks: D (how the last command ended), the directory and A before every prompt, B at
# its end, C when a command starts to run. PS0 sets __lembas_ran in this shell (an array index is
# evaluated here, not in a subshell), so an empty line ends no command. The path in OSC 7 is
# percent-encoded byte by byte.
__lembas_url() {
local LC_ALL=C s=$1 out= c i
for ((i = 0; i < \${#s}; i++)); do
c=\${s:i:1}
case $c in
[A-Za-z0-9/._~-]) out+=$c ;;
*) printf -v c '%%%02X' "'$c"; out+=$c ;;
esac
done
printf '%s' "$out"
}
__lembas_prompt() {
local s=$?
[ -n "$__lembas_ran" ] && printf '\\e]133;D;%s\\a' "$s"
__lembas_ran=
printf '\\e]7;file://%s%s\\a' "\${HOSTNAME:-localhost}" "$(__lembas_url "$PWD")"
printf '\\e]133;A\\a'
return $s
}
# B at the end of the prompt, put back after every hook that builds PS1 afresh (starship, a
# powerline prompt): this one runs last.
__lembas_mark() {
local s=$?
case $PS1 in *'\\[\\e]133;B\\a\\]') ;; *) PS1="\${PS1}\\[\\e]133;B\\a\\]" ;; esac
return $s
}
if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
PROMPT_COMMAND=(__lembas_prompt "\${PROMPT_COMMAND[@]}" __lembas_mark)
else
PROMPT_COMMAND="__lembas_prompt\${PROMPT_COMMAND:+; $PROMPT_COMMAND}; __lembas_mark"
fi
PS0="\${PS0}\\e]133;C\\a\\\${__lembas_nil[__lembas_ran=1]}"
`
const ZSH_HOOKS = `# Then the marks: D (how the last command ended), the directory and A before every prompt, B at
# its end, C when a command starts to run. The path in OSC 7 is percent-encoded byte by byte.
__lembas_url() {
emulate -L zsh
local LC_ALL=C s=$1 out= c i
for (( i = 1; i <= \${#s}; i++ )); do
c=\${s[i]}
if [[ $c == [A-Za-z0-9/._~-] ]]; then out+=$c; else out+=$(printf '%%%02X' "'$c"); fi
done
print -rn -- $out
}
__lembas_precmd() {
local s=$?
[[ -n $__lembas_ran ]] && printf '\\e]133;D;%s\\a' $s
__lembas_ran=
printf '\\e]7;file://%s%s\\a' "$HOST" "$(__lembas_url "$PWD")"
printf '\\e]133;A\\a'
return $s
}
# B at the end of the prompt, put back after any hook that builds PS1 afresh: this one runs last.
__lembas_mark() {
[[ $PS1 == *$'\\e]133;B\\a%}' ]] || PS1="$PS1%{"$'\\e]133;B\\a'"%}"
}
__lembas_preexec() { __lembas_ran=1; printf '\\e]133;C\\a' }
precmd_functions=(__lembas_precmd $precmd_functions __lembas_mark)
preexec_functions=($preexec_functions __lembas_preexec)
`
/** zsh: our ZDOTDIR's .zshenv and .zprofile read the user's own, from the directory they use — the
* one ZDOTDIR named when the terminal started, or HOME, or the one their own .zshenv sets (the
* XDG way). Our .zshrc hands ZDOTDIR back and drops our variables *before* it reads theirs, so a
* .zshrc that execs (tmux) leaves nothing of ours behind, and zsh then reads their .zlogin itself. */
const ZSHENV = `# LLeMbas CLI: shell integration (generated; deleted when the terminal closes).
__lembas_dir=$ZDOTDIR
ZDOTDIR=\${LEMBAS_USER_ZDOTDIR:-$HOME}
[[ -r $ZDOTDIR/.zshenv ]] && . $ZDOTDIR/.zshenv
# Their .zshenv may have moved their files elsewhere: the rest are read from there.
[[ $ZDOTDIR != \${LEMBAS_USER_ZDOTDIR:-$HOME} ]] && export LEMBAS_USER_ZDOTDIR_SET=1
export LEMBAS_USER_ZDOTDIR=$ZDOTDIR
ZDOTDIR=$__lembas_dir
unset __lembas_dir
`
const ZPROFILE = `# LLeMbas CLI: shell integration (generated; deleted when the terminal closes).
if [[ -r $LEMBAS_USER_ZDOTDIR/.zprofile ]]; then
__lembas_dir=$ZDOTDIR
ZDOTDIR=$LEMBAS_USER_ZDOTDIR
. $ZDOTDIR/.zprofile
ZDOTDIR=$__lembas_dir
unset __lembas_dir
fi
`
const ZSHRC = `# LLeMbas CLI: shell integration (generated; deleted when the terminal closes).
__lembas_user=$LEMBAS_USER_ZDOTDIR
if [[ -n $LEMBAS_USER_ZDOTDIR_SET ]]; then ZDOTDIR=$__lembas_user; else unset ZDOTDIR; fi
unset LEMBAS_USER_ZDOTDIR LEMBAS_USER_ZDOTDIR_SET
[[ -r $__lembas_user/.zshrc ]] && . $__lembas_user/.zshrc
unset __lembas_user
${ZSH_HOOKS}`
const FISH = `# LLeMbas CLI: shell integration (generated; deleted when the terminal closes). Run after your
# own config: D (how the last command ended) after it, the directory and A before every prompt, B
# at its end, C when a command starts.
function __lembas_preexec --on-event fish_preexec
printf '\\e]133;C\\a'
end
function __lembas_postexec --on-event fish_postexec
printf '\\e]133;D;%s\\a' $status
end
function __lembas_prompt --on-event fish_prompt
printf '\\e]7;file://%s%s\\a' (hostname) $PWD
printf '\\e]133;A\\a'
end
if functions -q fish_prompt
functions -c fish_prompt __lembas_user_prompt
function fish_prompt
__lembas_user_prompt
printf '\\e]133;B\\a'
end
end
`
export interface ShellLaunch {
kind: ShellKind
integration: boolean
argv: string[]
env: Record<string, string>
/** Remove what was written for it. */
cleanup(): void
}
/** How to start `shell` for terminal `id`: with the marks when it is bash, zsh or fish and
* `integration` is on; as `$SHELL -l` otherwise. */
export function shellLaunch(shell: string, id: string, integration: boolean, env: NodeJS.ProcessEnv = process.env): ShellLaunch {
const kind = shellKind(shell)
const plain: ShellLaunch = { kind, integration: false, argv: [shell, "-l"], env: {}, cleanup: () => {} }
if (!integration || kind === "other") return plain
const dir = join(paths.state, "terminal", id.replace(/[^\w-]/g, "_"))
const cleanup = () => rmSync(dir, { recursive: true, force: true })
try {
mkdirSync(dir, { recursive: true, mode: 0o700 })
if (kind === "bash") {
writeFileSync(join(dir, "bashrc"), BASH, { mode: 0o600 })
return { kind, integration: true, argv: [shell, "--rcfile", join(dir, "bashrc"), "-i"], env: {}, cleanup }
}
if (kind === "zsh") {
writeFileSync(join(dir, ".zshenv"), ZSHENV, { mode: 0o600 })
writeFileSync(join(dir, ".zprofile"), ZPROFILE, { mode: 0o600 })
writeFileSync(join(dir, ".zshrc"), ZSHRC, { mode: 0o600 })
return {
kind,
integration: true,
argv: [shell, "-l"],
env: { ZDOTDIR: dir, LEMBAS_USER_ZDOTDIR: env.ZDOTDIR || env.HOME || "", ...(env.ZDOTDIR ? { LEMBAS_USER_ZDOTDIR_SET: "1" } : {}) },
cleanup,
}
}
writeFileSync(join(dir, "integration.fish"), FISH, { mode: 0o600 })
return { kind, integration: true, argv: [shell, "-l", "-C", `source '${join(dir, "integration.fish").replace(/'/g, "\\'")}'`], env: {}, cleanup }
} catch {
cleanup()
return plain
}
}
+206
View File
@@ -0,0 +1,206 @@
// Turn ids: the web UI's prompt carries its own message id, and a prompt with an id this
// session has already taken does not run twice. That closes the two races of reattaching after a
// dropped link — the instance not knowing whether its prompt arrived, and a terminal's turn matched
// to the wrong row by timing: the instance asks (`_lembas/session/status`) and, when in doubt,
// simply sends the prompt again.
//
// Per session, the last 200 ids, each queued, running or done; a repeat of a queued or running one
// waits for the original and answers what it answers, a repeat of a finished one answers at once.
// The finished ones are kept in one file per session, which the service and a terminal both read
// and write — whichever holds the session — so a turn run in a terminal is not run again by the
// service after the terminal lets go of it, nor the other way round, nor after a restart.
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { paths } from "../config/paths.ts"
export type TurnState = "queued" | "running" | "done"
interface Entry<R> {
state: TurnState
result: Promise<R>
}
export const TURNS_KEPT = 200
/** A session's file not written for this long goes, the next time a process opens turn files. */
const TURN_FILE_DAYS = 30
/** Where a session's finished turns are kept: `[id, answer | null]` in order, null for a turn this
* side did not answer (one typed in a terminal). */
export interface TurnStore<R> {
load(): [string, R | null][]
save(done: [string, R | null][]): void
}
/** The file's entries, those that are entries: a damaged file is an empty one, never a crash. */
function readEntries<R>(file: string): [string, R | null][] {
try {
const v = JSON.parse(readFileSync(file, "utf8"))
if (!Array.isArray(v)) return []
return v.filter((e): e is [string, R | null] => Array.isArray(e) && e.length === 2 && typeof e[0] === "string" && e[0].length > 0)
} catch {
return []
}
}
/** A JSON file. Written whole to a temporary file and renamed over it, so a reader in the other
* process never sees half of one; what that process wrote meanwhile is merged in first. */
export function turnFile<R>(file: string): TurnStore<R> & { drop(): void } {
return {
load: () => readEntries<R>(file),
save(done) {
try {
const merged = new Map<string, R | null>(readEntries<R>(file))
for (const [id, v] of done) {
// Ours last, so they stay the newest; an answer never replaced by "none known".
const had = merged.get(id)
merged.delete(id)
merged.set(id, v ?? had ?? null)
}
const kept = [...merged].slice(-TURNS_KEPT)
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
writeFileSync(tmp, JSON.stringify(kept), { mode: 0o600 })
renameSync(tmp, file)
} catch {}
},
drop() {
rmSync(file, { force: true })
},
}
}
const turnsDir = () => join(paths.state, "turns")
let pruned = false
/** Files of sessions nobody has worked in for a month go (a deleted session's goes at once). */
export function pruneTurnFiles(dir = turnsDir(), maxAgeDays = TURN_FILE_DAYS) {
let names: string[]
try {
names = readdirSync(dir)
} catch {
return
}
const cutoff = Date.now() - maxAgeDays * 86_400_000
for (const n of names) {
const f = join(dir, n)
try {
if (statSync(f).mtimeMs < cutoff || n.endsWith(".tmp")) rmSync(f, { force: true })
} catch {}
}
}
/** The file of one session's turns. */
export function sessionTurnFile<R>(sessionId: string) {
return turnFile<R>(join(turnsDir(), `${sessionId.replace(/[^\w.-]/g, "_")}.json`))
}
/** One session's turn log, on its file — the one the service and every terminal use. */
export function sessionTurns<R>(sessionId: string): TurnLog<R> {
if (!pruned) {
pruned = true
pruneTurnFiles()
}
return new TurnLog<R>(sessionTurnFile<R>(sessionId))
}
export class TurnLog<R = unknown> {
private entries = new Map<string, Entry<R>>()
private values = new Map<string, R | null>()
/** The turn running now, and the last one that finished. */
running?: string
last?: string
constructor(private store?: TurnStore<R>) {
this.sync()
}
/** What the file says now (another process may have written it), for ids not known here. */
private sync() {
for (const [id, value] of this.store?.load() ?? []) {
if (this.entries.has(id)) continue
this.entries.set(id, { state: "done", result: Promise.resolve(value as R) })
this.values.set(id, value)
if (!this.running) this.last = id
}
}
has(id: string): boolean {
if (!this.entries.has(id)) this.sync()
return this.entries.has(id)
}
state(id: string): TurnState | undefined {
return this.entries.get(id)?.state
}
/** The ids waiting their turn, oldest first. */
queued(): string[] {
return [...this.entries].filter(([, e]) => e.state === "queued").map(([id]) => id)
}
/** Run `fn` as turn `id` — or, for an id already taken, answer what that turn answers. `fn` gets
* `started`, to call when the turn stops waiting and runs. */
run(id: string, fn: (started: () => void) => Promise<R>): Promise<R> {
if (!this.entries.has(id)) this.sync()
const known = this.entries.get(id)
if (known) return known.result
const entry: Entry<R> = { state: "queued", result: undefined as unknown as Promise<R> }
this.entries.set(id, entry)
this.trim()
entry.result = fn(() => {
entry.state = "running"
this.running = id
})
.then((value) => {
this.values.set(id, value)
return value
})
.finally(() => {
entry.state = "done"
if (this.running === id) this.running = undefined
this.last = id
this.persist()
})
// A failed turn is answered as failed to a repeat too, and is not an unhandled rejection here.
entry.result.catch(() => {})
return entry.result
}
/** A turn this log did not start (one typed in a terminal), followed by its events. */
started(id: string) {
const e = this.entries.get(id)
if (e) e.state = "running"
else {
this.entries.set(id, { state: "running", result: Promise.resolve(undefined as R) })
this.trim()
}
this.running = id
}
ended(id: string) {
const e = this.entries.get(id)
if (e) e.state = "done"
if (this.running === id) this.running = undefined
this.last = id
this.persist()
}
/** The oldest finished ones go first; one still queued or running is never forgotten. */
private trim() {
if (this.entries.size <= TURNS_KEPT) return
for (const [id, e] of this.entries) {
if (this.entries.size <= TURNS_KEPT) break
if (e.state === "done") {
this.entries.delete(id)
this.values.delete(id)
}
}
}
/** The finished turns, to the store: with their answers where this side gave one. */
private persist() {
if (!this.store) return
this.store.save([...this.entries].filter(([, e]) => e.state === "done").map(([id]) => [id, this.values.get(id) ?? null]))
}
}
+680
View File
@@ -0,0 +1,680 @@
// Wiring shared by every front end: load config, find the project, resolve the model, and build
// an Engine. The TUI and `run` both start here.
import { existsSync, mkdirSync, readFileSync } from "node:fs"
import { join, relative } from "node:path"
import { capacityRefusal } from "./session/capacity.ts"
import { fillDescription, TOOL_SPECS } from "./harness.ts"
import { duration } from "./duration.ts"
import { createWorktree, finishWorktree, type Worktree } from "./git/worktree.ts"
import { boardSummary } from "./project/board.ts"
import { unsafeProjectFile } from "./project/safe.ts"
import { Bus, type Asker } from "./bus/index.ts"
import { loadConfig, type Loaded } from "./config/load.ts"
import type { Effort, Mode } from "./config/schema.ts"
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
import { hardlineRules } from "./permission/hardline.ts"
import { defaultEffort } from "./provider/effort.ts"
import { clientFor, familyOf, resolveModel } from "./provider/index.ts"
import { discoverContext, unload } from "./provider/discover.ts"
import { assembleSystem, instructionFiles } from "./prompt/assemble.ts"
import { describeRepo, repoState } from "./git/repo.ts"
import { findProject, persistProjectRule, trustOf, type Project } from "./project/root.ts"
import type { Message } from "./provider/types.ts"
import { Engine, type EngineOptions } from "./session/engine.ts"
import { agents, NOT_FOR_SUBAGENTS } from "./project/agents.ts"
import { ToolError, type Tool } from "./tool/tool.ts"
import type { Client, ResolvedModel } from "./provider/types.ts"
import { modelTitle, firstReply, promptTitle } from "./session/title.ts"
import { Turns } from "./session/turns.ts"
import { Snapshots } from "./git/snapshot.ts"
import { CWD_META, REMOTE_META, Store } from "./session/store.ts"
import { BUILTIN_TOOLS } from "./tool/registry.ts"
import { DEFAULT_LIMITS, MemoryStore } from "./memory/store.ts"
import { loadSkills, skillLines, type Skill } from "./skill/index.ts"
import { personalityOf } from "./prompt/personality.ts"
import { McpManager } from "./mcp/index.ts"
import { VERSION } from "./version.ts"
import { Settings } from "./settings.ts"
import { Library, type Embedder } from "./library/store.ts"
import { instances as lembasInstances, keyFile as lembasKeyFile } from "./lembas/login.ts"
import { PERSONAL_KEYS, personalConfig, pushPersonalization } from "./lembas/personal.ts"
import { LIBRARY_SERVER } from "./mcp/index.ts"
import type { McpServer } from "./config/schema.ts"
import { embedderFor } from "./library/embed.ts"
import { effortsFor } from "./provider/effort.ts"
export interface AppOptions {
cwd: string
model?: string
mode?: Mode
/** undefined = the model's default; "off" = none */
effort?: Effort | "off"
asker: Asker
store?: Store | false
/** Nobody can answer an approval (headless, no --yes). Said in the system prompt up front. */
unattended?: boolean
/** Share a bus created earlier (the TUI builds its view before the app). */
bus?: Bus
/** false: no working-tree snapshots (headless runs). */
snapshots?: false
/** false: name sessions from the first prompt only (headless runs end before a second request could). */
modelTitles?: false
/** The stored session this app is opened for, to be resumed at once: no new session is
* made first. Without it every reopening — the service opening a web chat's session from the
* store — left an empty, untitled session behind in the project, listed by /sessions as its id. */
resuming?: string
}
const UNATTENDED: Record<Mode, string | undefined> = {
manual: "any file change, and any command not already allowed",
edit: "any command not already allowed, and anything outside the project",
plan: "anything but reading and the plan file",
auto: undefined,
}
export interface App {
bus: Bus
engine: Engine
loaded: Loaded
project: Project
trusted: boolean
/** The subagents the task tool offers (built-in, global, and a trusted project's), as of start. */
agents: import("./project/agents.ts").AgentSpec[]
store: Store | undefined
/** Every configured `connection/model`, in config order. */
modelRefs(): string[]
/** A ref in any form it may be written in (the old `<login>/<model>` too) as the ref the
* model has now; the ref itself when it names nothing. */
canonicalRef(ref: string): string
switchModel(ref: string): void
newSession(): void
/** Name the open session by hand (a chat renamed in the web UI); no model title follows. */
rename(title: string): void
/** Delete the open session when nothing was ever said in it — `lembas run` on its way
* out; the TUI goes through acp/share.ts (discardOnExit), which asks the hub first. True when
* it went. */
discardIfEmpty(): boolean
/** Load a stored session into the engine; returns what the UI should show (the full history). */
resume(id: string): Message[]
/** Snapshot-bracketed prompts: /undo, /redo, /diff. */
turns: Turns
snapshots: Snapshots | undefined
memory: MemoryStore
/** This project's own memory, when it has one. */
projectMemory?: MemoryStore
/** MCP servers. Connecting starts with the app; `mcpReady` settles when every server has
* connected or failed (headless runs wait for it). */
mcp: McpManager
mcpReady: Promise<void>
/** Stop what the app started (MCP server processes). */
close(): Promise<void>
/** Skills as they are on disk now. */
skills(): Skill[]
/** The model for small jobs (titles, commit messages): `small_model`, else the session's. */
small(): { model: ResolvedModel; client: Client }
/** Settings: what each is, where it comes from, changing them (/settings, the settings tool). */
settings: Settings
/** Notes and knowledge bases (library.db), and the embedding model, if one is set. */
library: Library
embedder?: Embedder
/** Stop whatever runs: the model's reply. */
cancel(): void
}
/** This machine's library tools, which `library: lembas` replaces with the instance's. */
const LOCAL_LIBRARY = new Set(["memory", "notes_search", "note_view", "note_manage", "knowledge_search", "knowledge_get", "skills_list", "skill_view", "skill_manage"])
function lembasLibraryServer(warnings: string[]): (McpServer & { source: "global" }) | undefined {
const all = Object.values(lembasInstances())
const keyMissing = all.length === 1 && !existsSync(lembasKeyFile(all[0]!.connection))
if (all.length !== 1 || keyMissing) {
if (keyMissing) warnings.push("library: lembas, but the login's key file is gone (lembas login again); using this machine's library")
else
warnings.push(all.length ? "library: lembas needs exactly one instance logged in to; using this machine's library" : "library: lembas, but not logged in to any instance (lembas login); using this machine's library")
return undefined
}
const one = all[0]!
return {
url: `${one.base_url}/mcp`,
// The token itself, read now: the config's {file:} substitution has already run by here.
headers: { authorization: `Bearer ${readFileSync(lembasKeyFile(one.connection), "utf8").trim()}` },
transport: "http",
oauth: false,
...(one.ca ? { tls: { ca: one.ca } } : {}),
source: "global",
}
}
/** Open a page in the user's browser, where there is one; false when there is not. */
function openInBrowser(url: string): boolean {
// Only a web page: a server's "authorization URL" could be file:, or a scheme a desktop runs.
if (!/^https?:\/\//i.test(url)) return false
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && process.platform === "linux") return false
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open"
try {
Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" }).unref()
return true
} catch {
return false
}
}
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
export function stricterMode(a: Mode | undefined, b: Mode): Mode {
return a !== undefined && MODE_RANK[a] > MODE_RANK[b] ? a : b
}
export function createApp(o: AppOptions): App {
const project = findProject(o.cwd)
const trust = trustOf(project.root)
const trusted = trust === "trusted"
const loaded = loadConfig({ projectConfigDir: project.dir, trusted })
const model = resolveModel(loaded, o.model ?? loaded.config.model)
// An untrusted project is read-only: plan mode, and its own config is ignored.
let mode: Mode = o.mode ?? loaded.config.mode ?? "manual"
if (trust === "readonly") mode = "plan"
// `effort:` in config when this model takes it, else the model's own default.
const startEffort = (m: ResolvedModel) => {
const e = loaded.config.effort
if (e === "off") return null
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.
loaded.config.search ??= {}
const bus = o.bus ?? new Bus()
const store = o.store === false ? undefined : (o.store ?? new Store())
const session = o.resuming ? undefined : store?.createSession(project.root, model.ref)
// Where it was started: reopened later — by the service for the web UI — it works there
// again, not at the project's root (agent.ts, workdirOf).
const keepCwd = (id: string | undefined) => {
if (!id || !store) return
try {
store.setMeta(id, CWD_META, o.cwd)
} catch {}
}
keepCwd(session?.id)
// settings_tool: allow lifts the settings tool's catch-all ask — rules somebody wrote still count.
const settingsTool = loaded.config.settings_tool ?? "ask"
const rules = [
...toRules(DEFAULT_RULES, "default"),
...(settingsTool === "allow" ? toRules({ settings: "allow" }, "global") : []),
...loaded.permissions.flatMap((p, i) => toRules(p, loaded.permissionSources[i] ?? "global")),
]
const planDir = join(project.dir, "plans")
// The git block: the repository as the session found it, never refreshed during it. It sits
// near the top of the system prompt, so any change to it — a file created or deleted, a commit
// — makes the server's prompt cache useless and the whole conversation is read again: on a
// 27B model holding 140k tokens, minutes of "waiting for the model". The model runs git status when it needs the state now.
const gitSummary = () => {
if (!project.gitRoot) return undefined
const s = repoState(project.gitRoot)
return s ? describeRepo(s) : undefined
}
// Memory and skills go into the system prompt as they stood when the session started: a write
// during the session reaches the file, not the prompt (Hermes' frozen snapshot).
const memoryOn = loaded.config.memory?.enabled !== false
const memory = new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: loaded.config.memory?.user_chars ?? DEFAULT_LIMITS.user })
const projectDir = () => (trusted && existsSync(project.dir) ? project.dir : undefined)
// The project's own memory: .agent/local (gitignored), in a trusted project that has one.
const projectMemory = projectDir() && !unsafeProjectFile(join(project.dir, "local", "MEMORY.md"), project.root)
? new MemoryStore({ memory: loaded.config.memory?.memory_chars ?? DEFAULT_LIMITS.memory, user: 0 }, join(project.dir, "local"), { memory: "PROJECT MEMORY (this project only)" })
: undefined
const skillList = () => loadSkills({ projectDir: projectDir(), external: loaded.config.skills?.external_dirs, disabled: loaded.config.skills?.disabled })
// Everything in the system prompt that could change during a session is taken once, here, for
// the same reason as the git block: the prompt must stay byte for byte the same, or the server
// reads the whole conversation again.
const freeze = () => ({
memory: memoryOn ? [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n") : undefined,
skills: skillLines(skillList()),
git: gitSummary(),
tasks: projectDir() && !unsafeProjectFile(join(project.dir, "tasks.md"), project.root) ? boardSummary(project.dir) : undefined,
files: instructionFiles(o.cwd, project.root, loaded.instructions),
})
let frozen = freeze()
// The library: one file for every project; an embedding model only when one is set (and usable).
const library = new Library()
let embedder: Embedder | undefined
try {
embedder = embedderFor(loaded, loaded.config.embedding)
} catch (e) {
loaded.warnings.push((e as Error).message)
}
const libraryCtx = { lib: library, project: project.root, bases: loaded.config.knowledge, embedder }
// library: lembas — the account's library through the instance's /mcp, in place of this
// machine's. Needs the one instance logged in to, with a token that has the library scope.
const lembasLibrary = loaded.config.library === "lembas" ? lembasLibraryServer(loaded.warnings) : undefined
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
const mcp = new McpManager(loaded.mcp, {
root: project.root,
version: VERSION,
notice: (message) => bus.emit({ type: "notice", message }),
open: openInBrowser,
})
const perm = {
rules,
hardline: hardlineRules({ extra: loaded.config.hardline_extra, disable: loaded.config.hardline_disable }),
root: project.root,
planDir,
projectDir: project.dir,
}
const baseToolCtx = () => ({
root: project.root,
cwd: o.cwd,
readFiles: new Set<string>(),
fileStamps: new Map<string, number>(),
bashTimeoutMs: (loaded.config.limits?.bash_timeout ?? 120) * 1000,
instructionsSeen: new Set(instructionFiles(o.cwd, project.root, loaded.instructions).map((f) => f.path)),
search: loaded.config.search ?? {},
planDir,
projectDir: projectDir(),
memory,
projectMemory,
library: libraryCtx,
skills: { list: skillList, projectDir: projectDir() },
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() },
})
const system = (m: Mode, current: ResolvedModel, subagent?: { name: string; instructions: string }, at?: { cwd: string; root: string }) =>
assembleSystem({
modelRef: current.ref,
family: familyOf(current),
cwd: at?.cwd ?? o.cwd,
root: at?.root ?? project.root,
instructions: loaded.instructions,
isGit: project.gitRoot !== undefined,
mode: m,
planDir,
toolNames: BUILTIN_TOOLS.map((t) => t.name),
unattended: o.unattended ? UNATTENDED[m] : undefined,
// A worktree subagent works elsewhere: its own snapshot, taken as it starts.
git: at ? gitSummary() : frozen.git,
tasks: frozen.tasks,
files: at ? undefined : frozen.files,
subagent,
memory: frozen.memory,
skills: frozen.skills,
manageSkills: true,
// Read on every prompt: /personality and /settings change it without a restart.
...personalityOf(loaded.config),
mcp: mcp.instructions(),
roster: roster(current.ref),
})
// The other configured models, for the task tool's `model`, or a switch.
const roster = (current: string) =>
loaded.refs
.map((r) => ({ ref: r.ref, spec: loaded.connections[r.connection]!.models[r.id]! }))
.filter((m) => m.ref !== current)
.slice(0, 15)
.map((m) => `- ${m.ref}${m.spec.name ? ` (${m.spec.name})` : ""}${m.spec.notes ? ` — ${m.spec.notes}` : ""}`)
.join("\n")
const compaction = { autoAt: loaded.config.compaction?.auto_at, prune: loaded.config.compaction?.prune }
// Subagents (the task tool): a child engine with its own bus and read-tracker, the agent's
// tools and mode, the same asker (permission prompts still reach the user), no task tool of its
// own. Only its final reply goes back; its tool lines are shown under the task line.
const agentList = agents(trusted ? project.dir : undefined)
const offered = BUILTIN_TOOLS.filter(
(t) => (memoryOn || t.name !== "memory") && (settingsTool !== "off" || t.name !== "settings") && !(lembasLibrary && LOCAL_LIBRARY.has(t.name)),
)
// The spec's descriptions carry the facts LLeMbas CLI fills in itself (harness/README.md).
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
const vars: Record<string, Record<string, string>> = {
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
bash: {
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
timeout_max: inWords(600),
background: " For a server, a watcher or anything long, set `background: true`: it returns at once with a job id; read its output with bash_output, list jobs with bash_list, stop one with bash_kill.",
},
}
const tools: Tool[] = offered.map((t) => (TOOL_SPECS[t.name]?.variables ? { ...t, description: fillDescription(t.description, vars[t.name] ?? {}) } : t))
const spawn: NonNullable<EngineOptions["spawn"]> = async (name, prompt, signal, callId, opts = {}) => {
const spec = agentList.find((a) => a.name === name)
if (!spec) throw new ToolError(`There is no agent "${name}". The agents are: ${agentList.map((a) => a.name).join(", ")}.`)
// 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 (src/session/capacity.ts), before anything is made.
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
const refused = capacityRefusal(engine.model, m)
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).
let wt: Worktree | undefined
if (opts.worktree || spec.isolation === "worktree") {
if (!project.gitRoot) throw new ToolError("worktree needs a git repository; this project is not one.")
try {
wt = createWorktree(project.gitRoot, `${spec.name}-${opts.description ?? ""}`)
} catch (e) {
throw new ToolError(`Could not make a worktree: ${(e as Error).message}`)
}
}
const at = wt ? { root: join(wt.dir, relative(project.gitRoot!, project.root)), cwd: join(wt.dir, relative(project.gitRoot!, o.cwd)) } : undefined
// A directory not committed yet is not in the checkout; make it, so the tools have somewhere to be.
if (at) for (const d of [at.root, at.cwd]) mkdirSync(d, { recursive: true })
// Isolation is the point: the main checkout is refused to it outright, in every mode —
// its prompt may well name files there by their absolute paths.
const childPerm = at
? {
...perm,
root: at.root,
rules: [...perm.rules, { permission: "external_directory", pattern: project.gitRoot!, action: "deny" as const }, { permission: "external_directory", pattern: `${project.gitRoot!}/*`, action: "deny" as const }],
}
: perm
const childTools = tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!spec.tools || spec.tools.includes(t.name)))
const childBus = new Bus()
let steps = 0
let calls = 0
childBus.on((e) => {
if (e.type === "step") steps++
// What a subagent writes counts toward the task's tokens.
if (e.type === "usage") bus.emit({ type: "usage", usage: e.usage, child: true })
if ((e.type === "tool_end" || e.type === "tool_denied") && callId) {
calls++
bus.emit({
type: "sub_tool",
callId,
name: e.name,
title: e.type === "tool_end" ? (e.result.title ?? e.name) : `denied — ${e.reason.split("\n")[0]!.slice(0, 80)}`,
isError: e.type === "tool_denied" || e.result.isError,
})
}
})
const child = new Engine({
bus: childBus,
asker: o.asker,
client: m.ref === engine.model.ref ? engine.client : clientFor(m),
model: m,
tools: childTools,
// An agent's own mode can make it stricter than its parent, never looser.
mode: stricterMode(spec.mode, engine.mode),
effort: m.ref === engine.model.ref ? engine.effort : startEffort(m),
maxSteps: 50,
compaction,
perm: childPerm,
toolCtx: at ? { ...baseToolCtx(), root: at.root, cwd: at.cwd, projectDir: undefined } : baseToolCtx(),
system: (mode, current) =>
system(
mode,
current,
{
name: spec.name,
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,
},
at,
),
})
const onAbort = () => child.cancel()
signal.addEventListener("abort", onAbort, { once: true })
try {
const reason = await child.prompt(prompt)
const last = [...child.messages].reverse().find((x) => x.role === "assistant")
let text = last && last.role === "assistant" ? last.parts.map((p) => (p.type === "text" ? p.text : "")).join("") : ""
if (wt) text += `\n\n[worktree] ${finishWorktree(project.gitRoot!, wt, `${spec.name}: ${opts.description ?? prompt.split("\n")[0]!.slice(0, 60)}`).summary}`
wt = undefined
return { text, steps, tools: calls, reason }
} finally {
signal.removeEventListener("abort", onAbort)
// Cancelled or failed: keep what it did, the same way.
if (wt) finishWorktree(project.gitRoot!, wt, `${spec.name}: ${opts.description ?? "unfinished"} (stopped)`)
}
}
const engine = new Engine({
bus,
asker: o.asker,
client: clientFor(model),
model,
tools,
store,
sessionId: session?.id,
mode,
effort,
maxSteps: loaded.config.limits?.steps,
budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
compaction,
perm,
toolCtx: baseToolCtx(),
system: (m, current) => system(m, current),
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
spawn,
fallback(current, tried) {
for (const ref of current.spec.fallback ?? []) {
if (tried.has(ref)) continue
try {
const m = resolveModel(loaded, ref)
return { model: m, client: clientFor(m), effort: startEffort(m) }
} catch {}
}
return undefined
},
})
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
let mcpNames = new Set<string>()
const syncMcp = () => {
const kept = tools.filter((t) => !t.name.startsWith("mcp__") && !mcpNames.has(t.name))
const fresh = mcp.tools()
mcpNames = new Set(fresh.map((t) => t.name))
tools.splice(0, tools.length, ...kept, ...fresh)
}
const seen = new Map<string, string>()
mcp.onChange(() => {
syncMcp()
for (const s of mcp.servers.values()) {
if (seen.get(s.name) === s.status) continue
seen.set(s.name, s.status)
if (s.status === "connected") bus.emit({ type: "notice", message: `MCP ${s.name}: connected — ${s.tools.length} tool${s.tools.length === 1 ? "" : "s"}${s.prompts.length ? `, ${s.prompts.length} prompt${s.prompts.length === 1 ? "" : "s"}` : ""}` })
else if (s.status === "failed" || s.status === "needs_auth") bus.emit({ type: "notice", message: `MCP ${s.name}: ${s.error ?? s.status}` })
}
})
const mcpReady = mcp.start()
// small_model when it resolves; a broken one is said once and the session's model used instead.
let smallWarned = false
const small = (): { model: ResolvedModel; client: Client } => {
const ref = loaded.config.small_model
if (ref && ref !== engine.model.ref)
try {
const m = resolveModel(loaded, ref)
return { model: m, client: clientFor(m) }
} catch (e) {
if (!smallWarned) bus.emit({ type: "notice", message: `small_model: ${(e as Error).message} — using ${engine.model.ref}` })
smallWarned = true
}
return { model: engine.model, client: engine.client }
}
// Titles: the first prompt names the session as it is sent; after the first reply the model may
// rename it. `named` is how far this session has got.
let named: "no" | "prompt" | "model" = "no"
let firstShown = ""
const byModel = () => o.modelTitles !== false && loaded.config.titles !== "prompt"
const setTitle = (id: string, title: string) => {
if (!title || !store) return
store.setTitle(id, title)
bus.emit({ type: "title", id, title })
}
bus.on((e) => {
const id = engine.sessionId
if (!store || !id) return
if (e.type === "prompt" && named === "no") {
named = "prompt"
firstShown = e.text
setTitle(id, promptTitle(e.text))
} else if (e.type === "done" && e.reason === "stop" && named === "prompt" && byModel()) {
named = "model"
const first = firstShown || store.firstPrompt(id)
const reply = firstReply(engine.messages)
const { client } = small()
const signal = AbortSignal.timeout(120_000)
// Quietly: a title is not worth a warning, and the prompt's line is already there.
void modelTitle(client, first, reply, signal)
.then((t) => t && engine.sessionId === id && setTitle(id, t))
.catch(() => {})
}
})
const snapshots = o.snapshots === false ? undefined : new Snapshots(project.root, project.gitRoot)
engine.busyInput = loaded.config.busy_input ?? "steer"
const turns = new Turns(engine, snapshots, store)
// After a reply, learn the window if nobody configured it (the model is loaded by then).
bus.on((e) => {
if (e.type === "done" && !engine.model.spec.context) void discoverContext(engine.model, engine.client).then((n) => n && engine.usage && bus.emit({ type: "usage", usage: engine.usage, context: n, used: engine.contextUsed() }))
})
// By provider for an instance's models (`deepseek/deepseek-flash`), src/provider/refs.ts.
const modelRefs = () => loaded.refs.map((r) => r.ref)
const switchModel = (ref: string) => {
const m = resolveModel(loaded, ref)
const prev = engine.model
engine.setModel(m, clientFor(m), startEffort(m))
// Moving to another connection frees the old one's memory (llama-swap /unload).
if (prev.connectionName !== m.connectionName) void unload(prev)
}
const setIn = (key: string, value: unknown) => {
const path = key.split(".")
let o = loaded.config as Record<string, unknown>
for (const k of path.slice(0, -1)) o = (o[k] ??= {}) as Record<string, unknown>
o[path[path.length - 1]!] = value
}
const settings = new Settings({
projectFile: trusted ? join(project.dir, "config.yaml") : undefined,
// Logged in, personalization is the account's: written to the instance, not config.yaml.
remote(key, v) {
const from = loaded.personalFrom
if (!from || !(PERSONAL_KEYS as readonly string[]).includes(key)) return undefined
void pushPersonalization(key as (typeof PERSONAL_KEYS)[number], String(v ?? ""))
// Opting in brings the other two back as they are saved there: the session uses them now.
.then((saved) => saved && Object.assign(loaded.config, personalConfig(saved), { [key]: v }))
.catch((e) =>
bus.emit({ type: "notice", level: "warn", message: `${key} is set for this session, but could not be saved to your account on ${from}: ${(e as Error).message}` }),
)
return `on ${from}, shared with the web UI`
},
live(key) {
if (key === "model") return engine.model.ref
if (key === "effort") return engine.effort ?? "off"
if (key === "mode") return engine.mode
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
},
check(key, v) {
if (key === "model" || key === "small_model") resolveModel(loaded, String(v))
if (key === "effort" && v !== "off" && !effortsFor(engine.model).includes(v as Effort))
throw new Error(`${engine.model.ref} takes ${effortsFor(engine.model).join(", ")} or off`)
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`)
},
apply(key, v) {
if (key === "model") switchModel(String(v))
else if (key === "effort") engine.effort = v === "off" ? null : (v as Effort)
else if (key === "mode") engine.mode = v as Mode
else if (key === "limits.steps") engine.o.maxSteps = Number(v)
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.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
else if (key === "compaction.prune") compaction.prune = Boolean(v)
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
bus.emit({ type: "setting", key, value: v })
},
})
// A mode changed inside a turn (a plan approved) is the session's from then on.
bus.on((e) => e.type === "mode" && settings.session.set("mode", e.mode))
/** Delete a session that holds no messages, saying so on the bus. Whether the web UI was
* shown it is read first, as the delete takes its meta with it. */
const discardEmpty = (id: string): boolean => {
if (!store) return false
try {
if (!store.session(id) || !store.isEmpty(id)) return false
const shown = store.meta<boolean>(id, REMOTE_META) === true
if (!store.deleteSession(id)) return false
bus.emit({ type: "discarded", id, shown })
return true
} catch {
return false
}
}
if (session) bus.emit({ type: "session", id: session.id, model: model.ref, mode })
for (const w of loaded.warnings) bus.emit({ type: "notice", message: w })
return {
bus,
engine,
loaded,
project,
trusted,
agents: agentList,
store,
turns,
snapshots,
modelRefs,
canonicalRef(ref) {
try {
return resolveModel(loaded, ref).ref
} catch {
return ref
}
},
switchModel,
settings,
memory,
projectMemory,
mcp,
mcpReady,
small,
close: () => mcp.close(),
skills: skillList,
library,
embedder,
cancel() {
engine.cancel()
},
rename(title) {
const id = engine.sessionId
if (!id || !title.trim()) return
named = "model"
setTitle(id, title.trim())
},
newSession() {
frozen = freeze()
named = "no"
firstShown = ""
const s = store?.createSession(project.root, engine.model.ref)
keepCwd(s?.id)
engine.newSession(s?.id)
turns.reset()
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
},
discardIfEmpty() {
const id = engine.sessionId
return id ? discardEmpty(id) : false
},
resume(id) {
if (!store) return []
const before = engine.sessionId
frozen = freeze()
engine.resume(id, store.context(id))
// A session from before titles gets its first prompt's line now, and the model's after the next reply.
firstShown = ""
const row = store.session(id)
if (row?.title) named = "model"
else {
const first = promptTitle(store.firstPrompt(id))
named = first ? "prompt" : "no"
if (first) setTitle(id, first)
}
turns.reset()
bus.emit({ type: "session", id, model: engine.model.ref, mode: engine.mode })
// The session open until now, when nobody said anything in it: the one every start
// makes, left behind by `-c` and /sessions as an empty, untitled row in /sessions and a chat
// in the web UI that holds nothing. Only when it is empty: one with a word in it is kept.
if (before && before !== id) discardEmpty(before)
return store.messages(id)
},
}
}
+116
View File
@@ -0,0 +1,116 @@
import type { Mode } from "../config/schema.ts"
import type { Decision, PermissionRequest } from "../permission/evaluate.ts"
import type { Usage } from "../provider/types.ts"
import type { PlanReply } from "../tool/plan_exit.ts"
import type { QuestionReply, QuestionRequest } from "../tool/question.ts"
import type { Todo } from "../tool/todo.ts"
import type { ToolResult } from "../tool/tool.ts"
// Everything the engine does is announced here. The TUI, the headless renderer and the tests
// are all just subscribers — the engine knows none of them.
export type Event =
| { type: "session"; id: string; model: string; mode: Mode }
/** The session open before a resume was empty and is gone from the store. `shown`: it had
* been in the web UI's hands, so the instance is to hear of it (acp/share.ts). */
| { type: "discarded"; id: string; shown: boolean }
/** A prompt starts: `text` as the user sees it (a command's name, not its expanded body).
* `turnId`: the turn it starts, the web UI's message id or one made here, so a client
* matches the turn to its own row by id rather than by timing. `attachments`: what went with it
* (pasted images, @files, a web upload), for chips beside the text. */
| { type: "prompt"; text: string; turnId?: string; attachments?: AttachmentInfo[] }
| { type: "step"; n: number }
/** The session was named (or renamed). */
| { type: "title"; id: string; title: string }
| { type: "text"; text: string }
| { type: "reasoning"; text: string }
| { type: "tool_call_delta"; index: number; name?: string; argsDelta: string }
/** The server reading the prompt, before the reply (llama.cpp): tokens in all, from its cache, read so far. */
| { type: "progress"; total: number; cache: number; processed: number; ms: number }
| { type: "tool_start"; id: string; name: string; args: unknown }
| { type: "tool_end"; id: string; name: string; result: ToolResult; ms: number }
| { type: "tool_denied"; id: string; name: string; reason: string }
| {
type: "usage"
usage: Usage
context?: number
/** What the next request will hold, where the engine knows better than input + output. */
used?: number
/** Output tokens per second in this step's reply. */
rate?: number
/** A subagent's: counts toward the task, says nothing about the session's context. */
child?: boolean
}
/** Messages the user sent while the task runs, not yet given to the model. */
| { type: "inbox"; texts: string[]; mode: "steer" | "queue" }
/** Those messages, given to the model now: at a step boundary, after the last step's tool results. */
/** `ids`: the ids their senders gave them (the web UI's message ids), where there were any. */
| { type: "steered"; texts: string[]; ids?: string[] }
/** A task — one prompt, with every step and tool it takes — starts or ends; `turnId` as on `prompt`. */
| { type: "task"; state: "start" | "end"; turnId?: string }
| { type: "notice"; message: string; level?: "info" | "warn" }
/** Discard the reply streamed so far in this step: it is being retried. */
| { type: "retract" }
| { type: "todos"; items: Todo[] }
/** The conversation was replaced by a summary of it (/compact, or automatically). */
| { type: "compacted"; summary: string }
/** A subagent's tool call finished (shown under the task line). */
| { type: "sub_tool"; callId: string; name: string; title: string; isError?: boolean }
/** The permission mode changed from inside a turn (plan approved). */
| { type: "mode"; mode: Mode }
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
* tool, a fallback, the web UI. `effort`: a level, or "off". */
| {
type: "model"
ref: string
effort: string
/** The connection it is spoken to through: a login's webui connection for an
* instance's model, whatever its ref says. */
connection?: string
/** Added by the link: the model is this link's instance's — the chat can take it. */
instance?: boolean
}
/** A word in the status bar that goes by itself ("Updated to 1.2.0"). */
| { type: "flash"; text: string }
/** A setting changed (/settings, the settings tool, a command): front ends redraw what shows it. */
| { type: "setting"; key: string; value: unknown }
| { type: "error"; message: string }
| { type: "done"; reason: "stop" | "steps" | "budget" | "cancelled" | "error" }
/** What went with a prompt, as a client shows it: a chip, not the content. */
export interface AttachmentInfo {
name: string
mimeType: string
size: number
}
export type Listener = (e: Event) => void
export class Bus {
private listeners = new Set<Listener>()
on(l: Listener): () => void {
this.listeners.add(l)
return () => this.listeners.delete(l)
}
emit(e: Event) {
for (const l of this.listeners) l(e)
}
}
export type AskReply =
/** `command`: the user corrected the command line before allowing it (a command's card only). */
| { kind: "once"; command?: string }
| { kind: "session" }
| { kind: "project" }
/** `final`: this will be refused every time in this session (nobody to ask) — stop offering the tool. */
| { kind: "deny"; feedback?: string; final?: boolean }
/** Whoever answers permission questions: the TUI's dialog, a y/n prompt, or a policy in headless runs. */
export interface Asker {
/** `preview`: the change the call would make (a unified diff), or why it could not be worked out. */
ask(req: { tool: string; args: unknown; request: PermissionRequest; decision: Decision; preview?: { diff?: string; error?: string }; /** What the model says the call is for. */ purpose?: string }): Promise<AskReply>
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
* id, which keys the card on another screen. */
question?(req: QuestionRequest, callId?: string): Promise<QuestionReply>
/** plan_exit. Absent: the plan is saved and the model told to stop. */
plan?(req: { path: string; text: string }, callId?: string): Promise<PlanReply>
}
+561
View File
@@ -0,0 +1,561 @@
#!/usr/bin/env bun
import { existsSync } from "node:fs"
import { join } from "node:path"
import { parseArgs } from "node:util"
import { createApp } from "./app.ts"
import { attachmentsFor } from "./project/attach.ts"
import { ConfigError, loadConfig } from "./config/load.ts"
import { asMode, EFFORTS, MODES, type Effort, type Mode } from "./config/schema.ts"
import { SettingError } from "./config/settings.ts"
import { renderJson, renderPlain, terminalAsker } from "./headless.ts"
import { findProject, setTrust, trustOf } from "./project/root.ts"
import { effortsFor } from "./provider/effort.ts"
import { clientFor, ModelError, resolveModel } from "./provider/index.ts"
import { Store } from "./session/store.ts"
import { promptTitle } from "./session/title.ts"
import { VERSION } from "./version.ts"
const HELP = `lembas ${VERSION} — terminal coding agent and project manager
Usage:
lembas [-m model] [--mode M] [-c | -s id]
the TUI (-c continues the last session here, -s resumes one)
lembas run [options] "…" one prompt, headless
lembas models [--discover] list configured models
lembas sessions list recent sessions
lembas sessions delete <id>… delete sessions, here and in the web UI
lembas sessions prune [--dry-run]
delete every session nobody said anything in, but the last
hour's (--dry-run: list them)
lembas config check validate ~/.config/lembas
lembas config list | get <key> | set <key> <value> [--project]
settings (the same as /settings in the TUI)
lembas config schema [--link]
write JSON Schemas for editors (--link: point the files at them)
lembas login [address] [--ca file] [--default] [--keep-services]
sign in to a LLeMbas instance: its models, voice and search
(again: read them now; --default pins its default model)
lembas logout [connection] sign out of one, and remove what login wrote
lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
lembas service install | uninstall | status | logs [-f] | run
work from a LLeMbas instance as a background service (remote:)
lembas mcp [list] connect the MCP servers and list their tools and prompts
lembas mcp auth <server> sign in to a remote MCP server (OAuth)
lembas mcp logout <server> forget a server's stored sign-in
lembas voice [check] what voice input and output use
lembas voice say "…" [-o f] speak (or write a WAV); voice transcribe <file>
lembas trust [--readonly] trust the current project (its .agent/ config is then honoured)
lembas update [--check] [--channel stable|beta] [vX.Y.Z] [--rollback]
install the newest release (or that one; --rollback: the one before)
lembas kb [create|add|docs|search|rm|reindex]
knowledge bases the agent can search (lembas kb help)
lembas uninstall [--purge] [--yes]
remove LLeMbas CLI (--purge: settings, sessions and memory too)
lembas --version
lembas --licenses the licences of everything this binary contains
Options for run:
-m, --model conn/model model to use (default: config.yaml \`model\`)
--mode MODE manual | edit | auto | plan
-e, --effort LEVEL ${EFFORTS.join(" | ")} | off
-y, --yes approve every ask (still never the hardline)
--json events as JSON lines
--reasoning show the model's reasoning
--no-store do not record the session
`
function fail(msg: string, code = 1): never {
process.stderr.write(`lembas: ${msg}\n`)
process.exit(code)
}
async function loginCommand(cmd: "login" | "logout", args: string[]) {
const { codeInstructions, instances, isWebuiEntry, login, loginSummary, logout, sync, LembasError } = await import("./lembas/login.ts")
const { values, positionals } = parseArgs({
args,
allowPositionals: true,
options: { ca: { type: "string" }, default: { type: "boolean" }, "keep-services": { type: "boolean" } },
})
const known = instances()
try {
if (cmd === "logout") {
const name = positionals[0] ?? (Object.keys(known).length === 1 ? Object.keys(known)[0]! : "")
if (!name) fail(Object.keys(known).length ? `which one? lembas logout <${Object.keys(known).join("|")}>` : "not logged in to any instance", 2)
const r = await logout(name)
console.log(`Signed out of ${name}${r.revoked ? "" : " here (the instance could not be told — revoke it under User → Security → Devices there)"}; its connection is removed.`)
if (r.modelLeft) console.log(`config.yaml still starts on ${r.modelLeft}: choose another with lembas config set model <connection/model>.`)
return
}
let address = positionals[0] ?? ""
// `login` again with no address refreshes the one instance there is.
const again = address ? Object.values(known).find((i) => i.base_url.replace(/^https?:\/\//, "") === address.replace(/^https?:\/\//, "").replace(/\/+$/, "")) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined
if (again && !values.ca && isWebuiEntry(again.connection)) {
const r = await sync(again.connection, { setDefault: values.default })
console.log(loginSummary(r, false).join("\n"))
return
}
// An older kind of login (the models copied into connections.yaml) is replaced by signing in
// again, at the address it had.
if (again && !address) address = again.base_url
if (!address) {
if (!process.stdin.isTTY) fail("usage: lembas login <instance address>", 2)
process.stdout.write("LLeMbas instance address: ")
for await (const line of console) {
address = line.trim()
break
}
if (!address) fail("no address given", 2)
}
const r = await login(address, {
ca: values.ca ?? again?.ca,
setDefault: values.default,
keepServices: values["keep-services"],
onCode: (start, d) => console.log(`\n${codeInstructions(start, d)}\n`),
})
console.log(loginSummary(r, true).join("\n"))
} catch (e) {
if (e instanceof LembasError) fail(e.message)
throw e
}
}
async function main() {
const argv = process.argv.slice(2)
const cmd = argv[0]
// install.sh asks where the three directories are before it writes anything into them.
// Internal, so not in --help; it prints where each one is, and install.sh writes where it says.
if (cmd === "__paths") {
const { paths } = await import("./config/paths.ts")
return void console.log(`config=${paths.config}\ndata=${paths.data}\nstate=${paths.state}`)
}
if (cmd === "-h" || cmd === "--help" || cmd === "help") return void process.stdout.write(HELP)
if (cmd === "--licenses" || cmd === "licenses") return void process.stdout.write((await import("../LICENSES.txt", { with: { type: "text" } })).default)
if (!cmd || cmd.startsWith("-") && !["-v", "--version"].includes(cmd)) {
const { values } = parseArgs({
args: argv,
options: {
model: { type: "string", short: "m" },
mode: { type: "string" },
effort: { type: "string", short: "e" },
continue: { type: "boolean", short: "c" },
session: { type: "string", short: "s" },
},
})
if (!process.stdin.isTTY || !process.stdout.isTTY) fail('the TUI needs a terminal; use: lembas run "…"', 2)
if (values.mode && !asMode(values.mode)) fail(`--mode must be one of ${MODES.join(", ")}`, 2)
await (await import("./lembas/webui.ts")).refreshWebui({ timeoutMs: 3000 })
const { runTui } = await import("./tui/index.tsx")
const done = await runTui({
cwd: process.cwd(),
model: values.model,
mode: asMode(values.mode),
effort: values.effort as Effort | "off" | undefined,
resume: values.session ?? (values.continue ? "last" : undefined),
})
if (done.reload) await (await import("./reload.ts")).restart(done.reload)
process.exit(0)
}
if (cmd === "--version" || cmd === "-v") return void console.log(VERSION)
if (cmd === "login" || cmd === "logout") return void (await loginCommand(cmd, argv.slice(1)))
// A webui connection's models are the instance's as they are now: read before anything that
// loads the configuration to use it. A few seconds at most; an instance that does not answer
// leaves what it said last.
if (["run", "models", "serve", "service", "voice", "config"].includes(cmd ?? "")) await (await import("./lembas/webui.ts")).refreshWebui({ timeoutMs: 3000 })
if (cmd === "serve") {
if (!argv.includes("--stdio")) fail("usage: lembas serve --stdio", 2)
const { Peer, stdioTransport } = await import("./acp/rpc.ts")
const { AcpAgent } = await import("./acp/agent.ts")
// stdout is the protocol: anything else printed would corrupt it, so it goes to stderr.
console.log = (...a: unknown[]) => console.error(...a)
const peer = new Peer(stdioTransport())
new AcpAgent(peer)
await new Promise<void>((resolve) => peer.onClose(resolve))
process.exit(0)
}
if (cmd === "service") {
const service = await import("./service.ts")
const sub = argv[1] ?? "status"
if (sub === "install") return void console.log(service.install().join("\n"))
if (sub === "uninstall") return void console.log(service.uninstall().join("\n"))
if (sub === "status") return void console.log(service.status().join("\n"))
if (sub === "logs") process.exit(service.logs(argv.includes("-f") || argv.includes("--follow")))
if (sub === "run") process.exit(await service.run(argv[2]))
fail("usage: lembas service install | uninstall | status | logs [-f] | run [connection]", 2)
}
if (cmd === "run") {
const { values, positionals } = parseArgs({
args: argv.slice(1),
allowPositionals: true,
options: {
model: { type: "string", short: "m" },
mode: { type: "string" },
effort: { type: "string", short: "e" },
yes: { type: "boolean", short: "y" },
json: { type: "boolean" },
reasoning: { type: "boolean" },
"no-store": { type: "boolean" },
},
})
let prompt = positionals.join(" ").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 (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))
fail(`--effort must be one of ${EFFORTS.join(", ")}, off`, 2)
const app = createApp({
cwd: process.cwd(),
model: values.model,
mode: asMode(values.mode),
effort: values.effort as Effort | "off" | undefined,
asker: terminalAsker({ yes: values.yes ?? false, unattended: loadConfig().config.question?.unattended, theme: loadConfig().config.theme }),
unattended: !values.yes && !process.stdin.isTTY,
store: values["no-store"] ? false : undefined,
snapshots: false,
modelTitles: false,
})
if (values.json) renderJson(app.bus)
else renderPlain(app.bus, { color: process.stderr.isTTY ?? false, showReasoning: values.reasoning ?? false, icons: app.loaded.config.icons, theme: app.loaded.config.theme })
process.on("SIGINT", () => app.engine.cancel())
// One prompt, so the MCP servers' tools must be there before it (each server has its own
// connect timeout; a failure is a notice, not a stop).
await app.mcpReady
const atts = attachmentsFor(prompt, app.engine.o.toolCtx, app.engine.model.spec.vision === true)
const reason = await app.engine.prompt(prompt, atts.flatMap((a) => (a.image ? [a.text, a.image] : [a.text])))
// Refused before the prompt was written (capacity, a cancel at once): no empty session is left
// to crowd `lembas sessions`. Never shared, so nobody else is told.
app.discardIfEmpty()
process.exit(reason === "stop" ? 0 : reason === "steps" || reason === "budget" ? 3 : reason === "cancelled" ? 130 : 1)
}
if (cmd === "models") {
const loaded = loadConfig()
const discover = argv.includes("--discover")
for (const [name, c] of Object.entries(loaded.connections)) {
console.log(
c.webui
? `${name} (webui, ${c.webui.url}${c.webui.name ? ` — ${c.webui.name}` : ""}; models ${c.webui.fetched_at ? `read ${c.webui.fetched_at.slice(0, 16).replace("T", " ")}` : "not read yet"})`
: `${name} (${c.dialect}, ${c.base_url})`,
)
for (const [id, spec] of Object.entries(c.models)) {
// Its ref: an instance's model by provider, anything else connection/model.
const ref = loaded.refs.find((r) => r.connection === name && r.id === id)?.ref ?? `${name}/${id}`
const m = resolveModel(loaded, ref)
const bits = [
spec.context ? `ctx ${spec.context}` : "",
spec.max_output ? `out ${spec.max_output}` : "",
spec.vision ? "vision" : "",
spec.tools === false ? "no-tools" : "",
spec.effort || spec.efforts ? `effort ${spec.effort ?? "off"} of ${effortsFor(m).join("/")}` : "",
].filter(Boolean)
console.log(` ${ref}${bits.length ? " — " + bits.join(", ") : ""}`)
}
if (discover) {
try {
const found = await clientFor(resolveModel(loaded, `${name}/${Object.keys(c.models)[0] ?? "?"}`)).listModels()
for (const d of found) if (!c.models[d.id]) console.log(` ${name}/${d.id} (discovered${d.context ? `, ctx ${d.context}` : ""})`)
} catch (e) {
console.log(` (discovery failed: ${(e as Error).message})`)
}
}
}
for (const [name, why] of Object.entries(loaded.broken)) console.log(`${name} ✗ ${why}`)
for (const w of loaded.warnings) console.error(`⚠ ${w}`)
return
}
if (cmd === "sessions" && (argv[1] === "delete" || argv[1] === "prune")) {
// Deleted the way /sessions deletes: a running hub lets go of the session first and
// tells the instance, so the web UI's chat goes too; with no hub, the word waits in the pending
// list for the service's next link (acp/pending.ts).
const { deleteEverywhere } = await import("./acp/share.ts")
const { addPendingDeleted } = await import("./acp/pending.ts")
const { REMOTE_META } = await import("./session/store.ts")
const store = new Store()
if (argv[1] === "delete") {
const ids = argv.slice(2).filter((a) => !a.startsWith("-"))
if (!ids.length) fail("usage: lembas sessions delete <id>…", 2)
let failed = 0
for (const id of ids) {
if (!store.session(id)) {
console.error(`${id}: no such session`)
failed++
continue
}
const why = await deleteEverywhere(store, id)
if (why) {
console.error(`${id}: not deleted — ${why}`)
failed++
} else console.log(`${id}: deleted`)
}
if (failed) process.exit(1)
return
}
const dry = argv.includes("--dry-run")
// Every session, not a page of them: the empty ones are what crowded the lists. Not one touched
// in the last hour: with no service running nothing can say whether a terminal has it open
// right now, empty because nobody has typed yet — and deleting that would fail its first turn.
const settled = Date.now() - 3_600_000
const empty = store.sessions(1_000_000).filter((s) => s.updated < settled && store.isEmpty(s.id))
if (dry) {
for (const s of empty) console.log(`${s.id} ${new Date(s.updated).toISOString().slice(0, 16).replace("T", " ")} ${s.root}`)
console.log(`${empty.length} empty session${empty.length === 1 ? "" : "s"} would be deleted`)
return
}
// The instance is told only of the ones it was shown (REMOTE_META): it never heard of the
// rest, and a prune of a thousand must not push real deletes out of the bounded pending list.
// Not through the hub one by one either: an empty session is held by nobody, and nobody is
// working in it, so there is nothing to release — it goes from the store, and the word with it.
const { connectHub } = await import("./acp/hub.ts")
const hub = await connectHub()
let n = 0
const told: string[] = []
for (const s of empty) {
if (hub) {
// A session open in a terminal right now, or held by the service, is not pruned from under it.
const free = await hub.request("hub/release", { sessionId: s.id }, 5000).then(() => true, () => false)
if (!free) continue
}
const shown = store.meta<boolean>(s.id, REMOTE_META) === true
if (!store.deleteSession(s.id)) continue
n++
if (shown) told.push(s.id)
}
if (hub) {
for (const id of told) hub.notify("hub/deleted", { sessionId: id })
await hub.request("hub/hello", { pid: process.pid }, 5000).catch(() => {})
hub.close()
} else addPendingDeleted(told)
console.log(`deleted ${n} empty session${n === 1 ? "" : "s"}`)
return
}
if (cmd === "sessions") {
const store = new Store()
// Only sessions somebody said something in: every start makes one, as /sessions knows.
for (const s of store.sessions(30, undefined, true)) {
// Sessions from before titles: their first prompt's line.
const title = s.title || promptTitle(store.firstPrompt(s.id))
console.log(`${s.id} ${new Date(s.updated).toISOString().slice(0, 16).replace("T", " ")} ${s.model} ${s.root}${title ? " — " + title : ""}`)
}
return
}
if (cmd === "config" && argv[1] === "schema") {
const { installSchemas, modeline, SCHEMA_FILES } = await import("./config/jsonschema.ts")
for (const line of installSchemas(undefined, argv.includes("--link"))) console.log(line)
if (!argv.includes("--link"))
console.log(
`\nAn editor with a YAML language server checks a file against these when its first line is:\n config.yaml: ${modeline(SCHEMA_FILES.config)}\n connections.yaml: ${modeline(SCHEMA_FILES.connections)}\n(lembas config schema --link adds them.)`,
)
return
}
if (cmd === "config" && (argv[1] === "list" || argv[1] === "get" || argv[1] === "set")) {
const { Settings } = await import("./settings.ts")
const { show, describe } = await import("./config/settings.ts")
const p = findProject(process.cwd())
const trusted = trustOf(p.root) === "trusted"
const project = argv.includes("--project")
if (project && !trusted) fail(`${p.root} is not a trusted project (lembas trust)`)
const args = argv.slice(2).filter((a) => a !== "--project")
// Out of a session: nothing is live, so a value is checked and written, no more — except where
// it is the account's (personalization, logged in), which is written there.
const { PERSONAL_KEYS, pushPersonalization } = await import("./lembas/personal.ts")
const pushes: Promise<unknown>[] = []
const settings = new Settings({
projectFile: trusted ? join(p.dir, "config.yaml") : undefined,
live: () => undefined,
apply: () => {},
remote(key, v) {
const loaded = loadConfig()
if (!loaded.personalFrom || !(PERSONAL_KEYS as readonly string[]).includes(key)) return undefined
pushes.push(pushPersonalization(key as (typeof PERSONAL_KEYS)[number], String(v ?? "")).catch((e) => fail(`could not save ${key} to ${loaded.personalFrom}: ${(e as Error).message}`)))
return `on ${loaded.personalFrom}, shared with the web UI`
},
check(key, v) {
if (key === "model" || key === "small_model") resolveModel(loadConfig(), String(v))
},
})
if (argv[1] === "list") {
for (const e of settings.list()) console.log(`${e.def.key} = ${show(e.value) || `(${e.def.fallback ?? "unset"})`}${e.source === "default" ? "" : ` [${e.source}]`}${e.note ? ` (${e.note})` : ""}`)
return
}
const [key, ...rest] = args
if (!key) fail(`usage: lembas config ${argv[1]} <key>${argv[1] === "set" ? " <value> [--project]" : ""}`, 2)
if (argv[1] === "get") {
const e = settings.get(key)
console.log(`${key} = ${show(e.value) || `(${e.def.fallback ?? "unset"})`}${e.source === "default" ? "" : ` [${e.source}]`}`)
if (e.note) console.log(`⚠ ${e.note}`)
const d = describe(e.def)
if (d) console.log(d)
return
}
if (!rest.length) fail(`usage: lembas config set ${key} <value> [--project]`, 2)
const said = settings.set(key, rest.join(" "), project ? "project" : "global").message
await Promise.all(pushes)
console.log(said)
return
}
if (cmd === "config" && argv[1] === "check") {
const p = findProject(process.cwd())
const trusted = trustOf(p.root) === "trusted"
const loaded = loadConfig({ projectConfigDir: p.dir, trusted })
if (!trusted && existsSync(join(p.dir, "config.yaml"))) console.log(`(${p.dir}/config.yaml not read: this project is not trusted — lembas trust)`)
console.log(`files: ${loaded.files.join(", ") || "(none)"}`)
console.log(`connections: ${Object.keys(loaded.connections).join(", ") || "(none)"}`)
for (const [name, why] of Object.entries(loaded.broken)) console.log(`✗ ${name}: ${why}`)
for (const w of loaded.warnings) console.log(`⚠ ${w}`)
// Another `lembas` first on PATH — the LLeMbas server's console script.
const shadowed = (await import("./doctor.ts")).lembasShadowed()
if (shadowed) console.log(`⚠ ${shadowed}`)
if (loaded.config.model) resolveModel(loaded, loaded.config.model)
console.log("ok")
return
}
if (cmd === "mcp") {
const { McpManager } = await import("./mcp/index.ts")
const p = findProject(process.cwd())
const loaded = loadConfig({ projectConfigDir: p.dir, trusted: trustOf(p.root) === "trusted" })
const sub = argv[1] ?? "list"
const name = argv[2]
const mcp = new McpManager(loaded.mcp, { root: p.root, version: VERSION, notice: (m) => console.error(`⚠ ${m}`) })
for (const [n, why] of Object.entries(loaded.broken)) if (n.startsWith("mcp:")) console.log(`✗ ${n.slice(4)}: ${why}`)
if (!mcp.servers.size && !Object.keys(loaded.broken).some((n) => n.startsWith("mcp:"))) {
console.log("no MCP servers configured (mcp: in ~/.config/lembas/config.yaml)")
process.exit(0)
}
if (sub === "list") {
await mcp.start()
for (const s of mcp.servers.values()) {
console.log(`${s.status === "connected" ? "●" : s.status === "disabled" ? "○" : "✗"} ${s.name} (${s.cfg.command !== undefined ? "local" : s.cfg.url}${s.cfg.source === "project" ? ", project" : ""}) ${s.status}${s.error ? ` — ${s.error}` : ""}`)
for (const t of s.tools) console.log(` ${t.name}${t.description ? ` — ${t.description.split("\n")[0]!.slice(0, 90)}` : ""}`)
for (const pr of s.prompts) console.log(` /${s.name}:${pr.name} (prompt)`)
if (s.resources) console.log(` (resources)`)
}
await mcp.close()
process.exit(0)
}
if (sub === "auth" && name) {
console.log(`Signing in to ${name}…`)
const rl = (await import("node:readline")).createInterface({ input: process.stdin })
const pasted = new Promise<string>((resolve) => rl.once("line", resolve))
const s = await mcp.auth(name, (url) => console.log(`\nOpen this page and sign in:\n\n ${url}\n\nIf the browser cannot reach this machine afterwards, paste the address it ends on here.`), pasted).catch((e) => fail((e as Error).message))
rl.close()
console.log(s.status === "connected" ? `${name}: signed in — ${s.tools.length} tools` : `${name}: ${s.error ?? s.status}`)
await mcp.close()
process.exit(s.status === "connected" ? 0 : 1)
}
if (sub === "logout" && name) {
mcp.logout(name)
console.log(`${name}: stored sign-in removed`)
process.exit(0)
}
fail("usage: lembas mcp [list] | mcp auth <server> | mcp logout <server>", 2)
}
if (cmd === "voice") {
const { synthesize, transcribe } = await import("./voice/speech.ts")
const { play, playerFor, recorderFor, PLAYER_NAMES } = await import("./voice/audio.ts")
const { spokenText, believable } = await import("./voice/text.ts")
const v = loadConfig().config.voice
const sub = argv[1] ?? "check"
if (sub === "check") {
const rec = recorderFor(v)
const pl = playerFor(v)
console.log(`input: ${v?.stt ? `${v.stt.provider ?? "openai"} ${v.stt.base_url ?? ""}` : "not set up (voice.stt)"}`)
console.log(`output: ${v?.tts ? `${v.tts.provider ?? "openai"} ${v.tts.base_url ?? v.tts.model_path ?? ""}` : "not set up (voice.tts)"}`)
console.log(`record: ${typeof rec === "string" ? `✗ ${rec}` : rec.join(" ")}`)
console.log(`play: ${typeof pl === "string" ? `✗ ${pl}` : `${pl.join(" ")} — the program that plays speech (${PLAYER_NAMES[pl[0]!] ?? "an audio player"})`}`)
if (v?.tts)
console.log(`\nTo hear it: lembas voice say "some text" here; in the TUI, /speak reads replies aloud and\n/voice test says one sentence (voice.speak: true in config.yaml speaks from the start).`)
process.exit(0)
}
if (sub === "say") {
const { values, positionals } = parseArgs({ args: argv.slice(2), options: { out: { type: "string", short: "o" } }, allowPositionals: true })
const text = spokenText(positionals.join(" ") || (await new Response(Bun.stdin.stream()).text()))
if (!text) fail("nothing to say", 2)
const audio = await synthesize(v, text).catch((e) => fail((e as Error).message))
if (values.out) {
await Bun.write(values.out, audio)
console.log(`${values.out}: ${audio.length} bytes`)
} else {
const pl = playerFor(v)
if (typeof pl === "string") fail(`${pl} (or give -o file.wav)`)
await play(pl, audio).done
}
process.exit(0)
}
if (sub === "transcribe" && argv[2]) {
const audio = new Uint8Array(await Bun.file(argv[2]).arrayBuffer())
const text = await transcribe(v, audio).catch((e) => fail((e as Error).message))
console.log(believable(text) || "(nothing recognisable)")
process.exit(0)
}
fail('usage: lembas voice [check] | voice say "text" [-o file.wav] | voice transcribe <file>', 2)
}
if (cmd === "update") {
const { autoUpdate, check, install, installTag, lock, rollback, installedBinary } = await import("./update/index.ts")
const { values, positionals } = parseArgs({
args: argv.slice(1),
allowPositionals: true,
options: { check: { type: "boolean" }, channel: { type: "string" }, rollback: { type: "boolean" } },
})
if (values.channel && values.channel !== "stable" && values.channel !== "beta") fail("--channel is stable or beta", 2)
// One update at a time: a session updating itself on start holds the same lock.
const unlock = lock()
if (!unlock) fail("another LLeMbas CLI is updating right now; try again in a minute")
process.on("exit", () => unlock())
if (values.rollback) {
const v = rollback()
console.log(`rolled back to ${v} (run it again to undo)`)
return
}
const config = { ...loadConfig().config.update, ...(values.channel ? { channel: values.channel as "stable" | "beta" } : {}) }
const say = (r: Awaited<ReturnType<typeof autoUpdate>>) => {
if (r.kind === "installed") console.log(`updated ${r.previous} → ${r.version} (${installedBinary()}); restart lembas, or /reload in a running one`)
else if (r.kind === "current") console.log(`${r.version} is the newest ${config.channel ?? "stable"} release`)
else if (r.kind === "available") console.log(`${r.version} is out — lembas update installs it`)
else if (r.kind === "skipped") console.log(r.reason)
else fail(r.reason)
}
if (positionals[0]) return say(await installTag(positionals[0], { config }))
const found = await check({ config })
if ("kind" in found) return say(found)
if (values.check) return say({ kind: "available", version: found.release.tag.replace(/^v/, "") })
return say(await install(found.release, found.version, { config }))
}
if (cmd === "kb") {
const { kbCommand } = await import("./library/cli.ts")
process.exit(await kbCommand(argv.slice(1)))
}
if (cmd === "uninstall") {
const { uninstall } = await import("./uninstall.ts")
process.exit(await uninstall({ purge: argv.includes("--purge"), yes: argv.includes("--yes") || argv.includes("-y") }))
}
if (cmd === "trust") {
const p = findProject(process.cwd())
setTrust(p.root, argv.includes("--readonly") ? "readonly" : "trusted")
console.log(`${p.root}: ${argv.includes("--readonly") ? "read-only" : "trusted"}`)
return
}
fail(`unknown command "${cmd}" — see lembas --help`, 2)
}
main().catch((e) => {
if (e instanceof ConfigError || e instanceof ModelError || e instanceof SettingError) fail(e.message)
fail((e as Error).stack ?? String(e))
})
+59
View File
@@ -0,0 +1,59 @@
// JSON Schemas for config.yaml and connections.yaml, made from the zod schemas — so an editor with
// a YAML language server (VS Code's YAML extension, Neovim's yamlls, Helix, Zed) completes keys,
// shows what each one does and marks mistakes as you type. `lembas config schema` writes them
// next to the config; schema/ in the repository holds the same files, for a project's .agent/config.yaml.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { z } from "zod"
import { paths } from "./paths.ts"
import { Config, ConnectionsFile } from "./schema.ts"
/** `{env:X}` and `{file:path}` can stand in for any string, so no string may be held to a
* format (a base_url of "{env:LLM_URL}" is not a URI until it is filled in). */
function loosen(node: unknown): unknown {
if (Array.isArray(node)) return node.map(loosen)
if (!node || typeof node !== "object") return node
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(node)) if (k !== "format") out[k] = loosen(v)
return out
}
export const SCHEMA_FILES = { config: "config.schema.json", connections: "connections.schema.json" } as const
// draft-07: the version every YAML language server reads.
export function jsonSchemas(): Record<keyof typeof SCHEMA_FILES, Record<string, unknown>> {
const make = (schema: z.ZodType, title: string, description: string) => ({
...(loosen(z.toJSONSchema(schema, { io: "input", unrepresentable: "any", target: "draft-7" })) as Record<string, unknown>),
title,
description,
})
return {
config: make(Config, "LLeMbas CLI config.yaml", "~/.config/lembas/config.yaml, or a project's .agent/config.yaml. Strings may be {env:NAME} or {file:path}."),
connections: make(ConnectionsFile, "LLeMbas CLI connections.yaml", "~/.config/lembas/connections.yaml: the LLM endpoints. Global only. Keys as {env:NAME}, {file:path} or key_cmd — never pasted."),
}
}
export const schemaText = (s: Record<string, unknown>) => JSON.stringify(s, null, 2) + "\n"
/** The comment that points a YAML language server at a schema, relative to the YAML file. */
export const modeline = (file: string) => `# yaml-language-server: $schema=schema/${file}`
/** Write both schemas under ~/.config/lembas/schema/. `link`: also point config.yaml and
* connections.yaml at them — a comment on the first line, added once. */
export function installSchemas(dir = paths.config, link = false): string[] {
const done: string[] = []
mkdirSync(join(dir, "schema"), { recursive: true })
const schemas = jsonSchemas()
for (const [name, file] of Object.entries(SCHEMA_FILES) as [keyof typeof SCHEMA_FILES, string][]) {
writeFileSync(join(dir, "schema", file), schemaText(schemas[name]))
done.push(`wrote ${join(dir, "schema", file)}`)
const yaml = join(dir, `${name}.yaml`)
if (!link || !existsSync(yaml)) continue
const text = readFileSync(yaml, "utf8")
if (text.includes("yaml-language-server: $schema=")) continue
// writeFileSync keeps the file's mode: connections.yaml stays 0600.
writeFileSync(yaml, `${modeline(file)}\n${text}`)
done.push(`${yaml}: added the schema comment on its first line`)
}
return done
}
+449
View File
@@ -0,0 +1,449 @@
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { isScalar, parse, parseDocument } from "yaml"
import { z } from "zod"
import { paths } from "./paths.ts"
import { asMode, Config, Connection, ConnectionsFile, McpServer, PERSONALITIES, renamePermissionKeys, WebuiConnection, type PermissionConfig } from "./schema.ts"
import { runKeyCmd, substituteDeep } from "./substitute.ts"
import { expandWebui, refreshFailures, type WebuiMark } from "../lembas/webui.ts"
import { defaultLibrary, withInstancePersonalization } from "../lembas/personal.ts"
import { instances } from "../lembas/login.ts"
import { findRef, modelRefs, type ModelRef } from "../provider/refs.ts"
export class ConfigError extends Error {}
export interface Loaded {
config: Config
/** A `type: webui` entry is here as the openai-chat connection it is spoken to as, with the
* instance's models and a `webui` mark. */
connections: Record<string, Connection & { webui?: WebuiMark }>
/** Connections whose {env:}/{file:} substitution failed, with the reason. Unusable, not fatal. */
broken: Record<string, string>
/** Permission rulesets in precedence order (global first). */
permissions: PermissionConfig[]
/** Where each of `permissions` came from: a project's rules cannot loosen a global one. */
permissionSources: ("global" | "project")[]
/** MCP servers, substituted; where each came from. A server whose substitution failed is in
* `broken` under `mcp:<name>`. */
mcp: Record<string, McpServer & { source: "global" | "project" }>
warnings: string[]
files: string[]
/** `instructions` from both files, kept apart: a project's may only name files inside it. */
instructions: { path: string; global: boolean }[]
/** Every model with its ref (an instance's by provider, `deepseek/deepseek-flash`). */
refs: ModelRef[]
/** The webui connection whose account's personalization is in force: /settings writes
* personality, personality_custom and instructions back there. */
personalFrom?: string
}
function readYaml(file: string): unknown {
try {
return parse(readFileSync(file, "utf8")) ?? {}
} catch (e) {
throw new ConfigError(`${file}: ${(e as Error).message}`)
}
}
function validate<S extends z.ZodType>(schema: S, value: unknown, file: string): z.infer<S> {
const r = schema.safeParse(value)
if (!r.success) {
const lines = r.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
throw new ConfigError(`${file} is invalid:\n${lines.join("\n")}`)
}
return r.data
}
/** config.yaml: everything substituted except `mcp` and `voice`, whose parts are substituted one
* by one later (a missing variable for one server or endpoint must not break the whole file). */
const MODE_RANK = { auto: 0, edit: 1, manual: 2, plan: 3 } as const
/** A project's change to a global server, kept only where it narrows: off, fewer tools, less in
* the prompt, shorter waits. It never turns on what the user turned off. */
function narrowed(base: McpServer, o: Record<string, unknown>): McpServer {
const out: Record<string, unknown> = { ...base }
for (const k of ["enabled", "instructions", "prompts", "resources"] as const) if (o[k] === false) out[k] = false
for (const k of ["timeout", "connect_timeout"] as const) {
const v = o[k]
if (typeof v === "number" && (base[k] === undefined || v < base[k]!)) out[k] = v
}
const t = (o.tools ?? {}) as { include?: string[]; exclude?: string[] }
if (t.include || t.exclude) {
const include = base.tools?.include ? (t.include ? base.tools.include.filter((x) => t.include!.includes(x)) : base.tools.include) : t.include
const exclude = [...new Set([...(base.tools?.exclude ?? []), ...(t.exclude ?? [])])]
out.tools = { ...(include ? { include } : {}), ...(exclude.length ? { exclude } : {}) }
}
return out as McpServer
}
// What a project may change about a server the global config defines, without redefining it.
const MCP_OVERRIDE = new Set(["enabled", "tools", "timeout", "connect_timeout", "prompts", "resources", "instructions"])
/** A project's config with every {env:}/{file:} value taken out (`removed` names them): only the
* user's own files may put a secret from the environment or a file into a URL or a header. */
function withoutPlaceholders(v: unknown, path: string, removed: string[]): unknown {
if (typeof v === "string") return /\{(env|file):[^}]+\}/.test(v) ? (removed.push(path), undefined) : v
if (Array.isArray(v)) return v.map((x, i) => withoutPlaceholders(x, `${path}[${i}]`, removed)).filter((x) => x !== undefined)
if (v && typeof v === "object") {
// A placeholder in one of its own values takes out the whole entry (a search service, an MCP
// server): what is left of it would be broken, or would go where it was not meant to.
const own = Object.entries(v).filter(([, x]) => typeof x === "string" && /\{(env|file):[^}]+\}/.test(x))
if (own.length) {
for (const [k] of own) removed.push(path ? `${path}.${k}` : k)
return undefined
}
const out: Record<string, unknown> = {}
for (const [k, x] of Object.entries(v)) {
const y = withoutPlaceholders(x, path ? `${path}.${k}` : k, removed)
if (y !== undefined) out[k] = y
}
return out
}
return v
}
/** What was taken out or reshaped, read from an older config, so an old file still
* starts: crowd chats and named personalities are gone (dropped, said once), a personality that
* is not a preset any more becomes none, and `instructions` as a list of files is now
* `instruction_files` (`instructions` became the custom-instructions text). */
function legacyKeys(all: Record<string, unknown>, file: string, notes: string[]) {
if ("crowd" in all) {
delete all.crowd
notes.push(`${file}: crowd chats were removed; \`crowd\` is ignored — delete it`)
}
if ("personalities" in all) {
delete all.personalities
notes.push(`${file}: named personalities were removed; \`personalities\` is ignored — personality is one of ${PERSONALITIES.join(", ")} now (custom takes personality_custom)`)
}
const p = all.personality
if (p !== undefined && !(typeof p === "string" && (p === "" || (PERSONALITIES as readonly string[]).includes(p)))) {
delete all.personality
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`)
}
// One file written as a plain string, not a list: read as the list of that one, not dropped
// without a word by the schema.
if (typeof all.instruction_files === "string") {
all.instruction_files = all.instruction_files.trim() ? [all.instruction_files] : []
notes.push(`${file}: instruction_files is a list; the one file given is read as a list of one — write it as [${String((all.instruction_files as string[])[0] ?? "")}]`)
}
if (Array.isArray(all.instructions)) {
all.instruction_files = [...(Array.isArray(all.instruction_files) ? all.instruction_files : []), ...all.instructions]
delete all.instructions
notes.push(`${file}: \`instructions\` is the custom-instructions text now; the list of files is read as instruction_files — rename it`)
}
}
function readConfig(file: string, label: string, notes: string[], dropped?: string[], mcpOverrides?: Record<string, Record<string, unknown>>, placeholders?: string[]): Config {
const raw = readYaml(file)
let all = raw && typeof raw === "object" ? { ...(raw as Record<string, unknown>) } : {}
if (placeholders) all = withoutPlaceholders(all, "", placeholders) as Record<string, unknown>
legacyKeys(all, file, notes)
// A project's global-only keys go before validation: ignored, so their shape cannot break the file.
if (dropped)
for (const k of GLOBAL_ONLY)
if (k in all) {
delete all[k]
dropped.push(k)
}
// 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.
// update: filled in when an update is looked for — a token file missing must not stop a start.
let { mcp, voice, search, update, ...rest } = all
// Names the harness spec renamed (v1): an old mode or permission key is read as the new one.
if (typeof rest.mode === "string") rest.mode = asMode(rest.mode) ?? rest.mode
if (rest.permission && typeof rest.permission === "object" && !Array.isArray(rest.permission)) rest.permission = renamePermissionKeys(rest.permission as Record<string, unknown>)
// A project's entry that only switches a global server off or narrows it (no command, no url)
// is kept apart: on its own it is not a valid server.
if (mcpOverrides && mcp && typeof mcp === "object" && !Array.isArray(mcp)) {
const full: Record<string, unknown> = {}
for (const [name, v] of Object.entries(mcp as Record<string, unknown>)) {
const keys = v && typeof v === "object" && !Array.isArray(v) ? Object.keys(v) : []
if (keys.length && keys.every((k) => MCP_OVERRIDE.has(k))) mcpOverrides[name] = v as Record<string, unknown>
else full[name] = v
}
mcp = full
}
return validate(Config, { ...substituteDeep(rest, label), ...(mcp === undefined ? {} : { mcp }), ...(voice === undefined ? {} : { voice }), ...(search === undefined ? {} : { search }), ...(update === undefined ? {} : { update }) }, file)
}
/** Objects merge key by key; anything else is replaced. Permission is handled separately (it stacks). */
function merge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T {
const out: Record<string, unknown> = { ...a }
for (const [k, v] of Object.entries(b)) {
const prev = out[k]
out[k] =
v && typeof v === "object" && !Array.isArray(v) && prev && typeof prev === "object" && !Array.isArray(prev)
? merge(prev as Record<string, unknown>, v as Record<string, unknown>)
: v
}
return out as T
}
// Keys a project may never set: they would let a cloned repository weaken the floor. The custom
// instructions and a custom personality's text are the person's own words, put last in the system
// prompt as theirs: a repository writing them would speak in the user's voice.
// A personality preset's name is only a choice among the shipped texts, and stays a project's to make.
const GLOBAL_ONLY = ["hardline_extra", "hardline_disable", "voice", "update", "settings_tool", "embedding", "remote", "library", "instructions", "personality_custom"] as const
export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean } = {}): Loaded {
const warnings: string[] = []
const files: string[] = []
const globalFile = join(paths.config, "config.yaml")
let config: Config = {}
const permissions: PermissionConfig[] = []
const permissionSources: ("global" | "project")[] = []
if (existsSync(globalFile)) {
config = readConfig(globalFile, "config.yaml", warnings)
files.push(globalFile)
if (config.permission) permissions.push(config.permission), permissionSources.push("global")
}
const instructions: { path: string; global: boolean }[] = (config.instruction_files ?? []).map((path) => ({ path, global: true }))
const mcpSources: Record<string, "global" | "project"> = {}
for (const name of Object.keys(config.mcp ?? {})) mcpSources[name] = "global"
if (opts.projectConfigDir && opts.trusted) {
const projectFile = join(opts.projectConfigDir, "config.yaml")
if (existsSync(projectFile)) {
const dropped: string[] = []
const placeholders: string[] = []
const overrides: Record<string, Record<string, unknown>> = {}
const project = readConfig(projectFile, "project config.yaml", warnings, dropped, overrides, placeholders)
for (const key of dropped) warnings.push(`${projectFile}: \`${key}\` is honoured only in the global config; ignored`)
for (const at of placeholders) warnings.push(`${projectFile}: ${at} uses {env:} or {file:}, which only the global config may; ignored`)
files.push(projectFile)
// The settings tool's gate is global-only (settings_tool); a project rule for it would open it.
if (project.permission && "settings" in project.permission) {
delete project.permission.settings
warnings.push(`${projectFile}: permission.settings is honoured only in the global config (settings_tool); ignored`)
}
if (project.permission) permissions.push(project.permission), permissionSources.push("project")
// A project may make the starting mode stricter than the user's, never looser.
const own = config.mode ?? "manual"
if (project.mode && MODE_RANK[project.mode] < MODE_RANK[own]) {
warnings.push(`${projectFile}: mode ${project.mode} is looser than your own (${own}); a project can only make it stricter — ${own} it is`)
delete project.mode
}
for (const name of Object.keys(project.mcp ?? {})) mcpSources[`project:${name}`] = "project"
const { permission: _, mcp: projectMcp, instruction_files: projectInstructions, ...rest } = project
// A search service the project names replaces yours whole: merged field by field, your api_key
// would go to the project's base_url (the same reason as for MCP servers below).
const ownSearch = { ...config.search }
config = merge(config, rest)
for (const svc of ["searxng", "firecrawl"] as const)
if (rest.search?.[svc]) config = { ...config, search: { ...ownSearch, ...config.search, [svc]: rest.search[svc] } }
for (const path of projectInstructions ?? []) instructions.push({ path, global: false })
// A server the project defines replaces the global one whole: merged field by field, the
// global one's headers (a token) would go to the project's url.
const servers: Record<string, McpServer> = { ...config.mcp, ...projectMcp }
for (const [name, o] of Object.entries(overrides)) {
const base = config.mcp?.[name]
if (!base) {
warnings.push(`${projectFile}: mcp.${name} changes a server the global config does not define; ignored`)
continue
}
const r = McpServer.safeParse(narrowed(base, o))
if (r.success) {
servers[name] = r.data
mcpSources[`project:${name}`] = "global"
} else warnings.push(`${projectFile}: mcp.${name}: ${r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}; ignored`)
}
if (Object.keys(servers).length) config = { ...config, mcp: servers }
}
}
// `skin` was renamed `theme`; an old config keeps its look.
if (config.skin !== undefined) {
if (config.theme === undefined) config = { ...config, theme: config.skin }
warnings.push("config: `skin` is now called `theme`; rename it (the value is used meanwhile)")
}
const connFile = join(paths.config, "connections.yaml")
const connections: Loaded["connections"] = {}
const broken: Record<string, string> = {}
if (existsSync(connFile)) {
files.push(connFile)
const mode = statSync(connFile).mode & 0o777
if (mode & 0o077) warnings.push(`${connFile} is mode ${mode.toString(8)}; chmod 600 it — it references credentials`)
// Validate the raw file, then substitute each connection on its own: a missing
// variable for one connection must not stop the others from working.
const raw = validate(ConnectionsFile, readYaml(connFile), connFile).connections
for (const [name, c] of Object.entries(raw)) {
try {
if ((c as { type?: string }).type === "webui") {
const w = WebuiConnection.safeParse(substituteDeep(c, `connections.${name}`))
if (w.success) {
const c = (connections[name] = expandWebui(name, w.data))
if (refreshFailures[name])
warnings.push(
`${name}: the instance could not be asked for its models (${refreshFailures[name]}); ${c.webui.fetched_at ? `using what it said at ${c.webui.fetched_at.slice(0, 16).replace("T", " ")}` : "none known yet"}`,
)
}
else broken[name] = `once {env:}/{file:} are filled in: ${w.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
continue
}
// Again once filled in: "{env:LLM_URL}" passed as a placeholder; what it became must be a URL.
const r = Connection.safeParse(substituteDeep(c, `connections.${name}`))
if (r.success) connections[name] = r.data
else broken[name] = `once {env:}/{file:} are filled in: ${r.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
} catch (e) {
broken[name] = (e as Error).message
}
}
}
/** The webui connection a voice half or the search means: the one named, or the only one. A
* string says why there is none. */
const webuiFor = (named?: string): { name: string; c: Loaded["connections"][string] } | string => {
if (named) return connections[named]?.webui ? { name: named, c: connections[named]! } : broken[named] ? `${named} is unusable: ${broken[named]}` : `there is no webui connection named ${named}`
const all = Object.entries(connections).filter(([, c]) => c.webui)
if (all.length === 1) return { name: all[0]![0], c: all[0]![1] }
return all.length ? `there are several webui connections (${all.map(([n]) => n).join(", ")}); name one with connection:` : "there is no webui connection (lembas login)"
}
const mcp: Loaded["mcp"] = {}
for (const [name, server] of Object.entries(config.mcp ?? {})) {
try {
mcp[name] = { ...substituteDeep(server, `mcp.${name}`), source: mcpSources[`project:${name}`] ?? mcpSources[name] ?? "global" }
} catch (e) {
broken[`mcp:${name}`] = (e as Error).message
warnings.push(`MCP server ${name} is off: ${(e as Error).message}`)
}
}
// Voice: each half on its own; a half whose substitution fails is off, with a warning.
if (config.voice) {
const v = { ...config.voice }
for (const half of ["stt", "tts"] as const) {
if (!v[half]) continue
try {
v[half] = substituteDeep(v[half], `voice.${half}`) as never
} catch (e) {
delete v[half]
broken[`voice:${half}`] = (e as Error).message
warnings.push(`voice ${half === "stt" ? "input" : "output"} is off: ${(e as Error).message}`)
}
}
// provider: webui — the instance's speech servers, reached at <url>/v1 with this machine's key.
// An instance that is not configured here falls back (or the half is off), with a warning.
for (const half of ["stt", "tts"] as const) {
const h = v[half]
if (h?.provider !== "webui") continue
const found = webuiFor(h.connection)
if (typeof found !== "string") {
try {
v[half] = { ...h, base_url: `${found.c.webui!.url}/v1`, api_key: resolveKey(found.name, found.c), tls: found.c.tls, connection: found.name } as never
continue
} catch (e) {
broken[`voice:${half}`] = (e as Error).message
}
}
const why = typeof found === "string" ? found : broken[`voice:${half}`]!
if (h.fallback) {
v[half] = h.fallback as never
warnings.push(`voice ${half === "stt" ? "input" : "output"}: webui — ${why}; using its fallback`)
} else {
delete v[half]
warnings.push(`voice ${half === "stt" ? "input" : "output"} is off: webui — ${why}`)
}
}
config = { ...config, voice: v }
}
// Search: each service on its own, like voice — a key that cannot be read turns off that
// service (the next in search.order is used), with a warning, and LLeMbas CLI still starts.
if (config.search) {
const sr = { ...config.search }
for (const svc of ["searxng", "firecrawl"] as const) {
if (!sr[svc]) continue
try {
const filled = substituteDeep(sr[svc], `search.${svc}`) as { base_url?: string }
if (filled.base_url !== undefined && !URL.canParse(filled.base_url)) throw new Error(`base_url is not a URL once {env:}/{file:} are filled in`)
sr[svc] = filled as never
} catch (e) {
delete sr[svc]
broken[`search:${svc}`] = (e as Error).message
warnings.push(`search: ${svc} is off: ${(e as Error).message}`)
}
}
// webui: the instance's own search (and fetch), with this machine's key.
if (sr.order?.includes("webui") || sr.webui || sr.fetch === "webui") {
const found = webuiFor(sr.webui?.connection)
let why = typeof found === "string" ? found : ""
if (typeof found !== "string") {
try {
sr.webui = { connection: found.name, base_url: found.c.webui!.url, api_key: resolveKey(found.name, found.c), tls: found.c.tls } as never
} catch (e) {
why = (e as Error).message
}
}
if (why) {
delete sr.webui
if (sr.fetch === "webui") sr.fetch = "local"
warnings.push(`search: webui is off — ${why}`)
}
}
config = { ...config, search: sr }
}
// Logged in to one instance: the account's personalization in place of the local keys,
// where the instance offers it; and its library when nobody chose one.
const personal = withInstancePersonalization(config)
config = personal.config
if (config.library === undefined && defaultLibrary()) config = { ...config, library: "lembas" }
// An instance that speaks protocol 2 serves `<provider>/<model>` ids.
const logins = instances()
for (const [name, c] of Object.entries(connections)) if (c.webui && logins[name]?.protocols?.includes(2)) c.webui.v2 = true
const refs = modelRefs(connections, Object.keys(logins))
// A config.yaml naming the model the way it was named before providers (`example/deepseek-flash`)
// is rewritten to the new name, once (migrateRefs). Only that form: a pinned `<login>/<provider>/
// <model>` means what it says, and `embedding` stays as written — its name keys the stored vectors.
for (const key of ["model", "small_model"] as const) {
const v = config[key]
if (typeof v !== "string") continue
const r = findRef(connections, refs, v)
if (!r?.old) continue
config = { ...config, [key]: r.ref }
migrateRef(globalFile, key, v, r.ref)
}
return { config, connections, broken, permissions, permissionSources, warnings, files, mcp, instructions, refs, ...(personal.connection ? { personalFrom: personal.connection } : {}) }
}
/** `key: old` in the global config.yaml becomes `key: now` — once per key and old value, recorded
* in the state directory; the scalar edited in place (its anchor and comment kept), re-read just
* before writing and written atomically, so a login writing the same file meanwhile is not lost. */
function migrateRef(file: string, key: string, old: string, now: string) {
const record = join(paths.state, "migrations.json")
let done: Record<string, string> = {}
try {
done = JSON.parse(readFileSync(record, "utf8")) as Record<string, string>
} catch {}
const id = `provider-refs:${key}:${old}`
if (done[id]) return
try {
if (existsSync(file)) {
const doc = parseDocument(readFileSync(file, "utf8"))
const node = doc.get(key, true)
if (isScalar(node) && node.value === old) {
node.value = now
const tmp = `${file}.${process.pid}.tmp`
writeFileSync(tmp, doc.toString())
renameSync(tmp, file)
}
}
done[id] = now
mkdirSync(paths.state, { recursive: true })
writeFileSync(record, JSON.stringify(done, null, 2) + "\n")
} catch {}
}
/** The API key for a connection: `api_key` (already substituted) or the output of `key_cmd`. */
export function resolveKey(name: string, c: Connection): string | undefined {
if (c.api_key) return c.api_key
if (c.key_cmd) return runKeyCmd(c.key_cmd, `connections.${name}.key_cmd`)
return undefined
}
+36
View File
@@ -0,0 +1,36 @@
import { homedir } from "node:os"
import { isAbsolute, join } from "node:path"
/** An environment variable under its LLeMbas CLI name: LEMBAS_<name>. */
export function env(name: string): string | undefined {
return process.env[`LEMBAS_${name}`]
}
// XDG base directories, overridable for tests with LEMBAS_HOME (which roots all three).
function base(envVar: string, fallback: string): string {
const root = env("HOME")
if (root) return join(root, fallback.split("/").pop()!)
// The XDG spec: a relative value is to be ignored (it would mean wherever the CLI was started).
const v = process.env[envVar]
return v && isAbsolute(v) ? v : join(homedir(), fallback)
}
const dir = (parent: string): string => join(parent, "lembas")
export const paths = {
get config() {
return dir(base("XDG_CONFIG_HOME", ".config"))
},
get data() {
return dir(base("XDG_DATA_HOME", ".local/share"))
},
get state() {
return dir(base("XDG_STATE_HOME", ".local/state"))
},
}
export function expandHome(p: string): string {
if (p === "~") return homedir()
if (p.startsWith("~/")) return join(homedir(), p.slice(2))
return p
}
+312
View File
@@ -0,0 +1,312 @@
import { z } from "zod"
export const DIALECTS = ["openai-chat", "responses", "anthropic", "gemini", "ollama"] as const
export const EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const
export const MODES = ["manual", "edit", "auto", "plan"] as const
/** The personality presets (harness/prompts/personality/, shared with the web UI), then custom. */
export const PERSONALITY_PRESETS = ["concise", "pragmatic", "optimistic", "funny", "formal", "socratic"] as const
export const PERSONALITIES = [...PERSONALITY_PRESETS, "custom"] as const
/** Older names of a mode, still read wherever a mode is written by hand (harness spec v1 renamed
* `unrestricted` to `auto`, LLeMbas's word). */
export const MODE_ALIASES: Record<string, (typeof MODES)[number]> = { unrestricted: "auto" }
export const ACTIONS = ["allow", "ask", "deny"] as const
export type Dialect = (typeof DIALECTS)[number]
export type Effort = (typeof EFFORTS)[number]
export type Mode = (typeof MODES)[number]
export type Action = (typeof ACTIONS)[number]
/** Permission keys the harness spec renamed (v1), read as the new ones in a config's `permission:`. */
export const PERMISSION_ALIASES: Record<string, string> = { question: "ask_user", plan_exit: "plan_submit", websearch: "web_search", webfetch: "web_fetch" }
export function renamePermissionKeys(p: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(p)) {
const to = PERMISSION_ALIASES[k]
// The new name written as well wins: it is the one somebody wrote on purpose.
if (to) out[to] ??= v
else out[k] = v
}
return out
}
/** A mode as written (a config file, --mode, /mode, an agent file, the settings tool), with the
* old names read as the new; undefined when it is not one. */
export function asMode(v: unknown): Mode | undefined {
if (typeof v !== "string") return undefined
const t = v.trim()
if ((MODES as readonly string[]).includes(t)) return t as Mode
return MODE_ALIASES[t]
}
const Tristate = z.enum(["auto", "on", "off"])
/** A URL — or a string that becomes one when its {env:}/{file:} is filled in. Files are validated
* before substitution, so the placeholder must pass here; the loader checks the result again. */
const Url = z.string().refine((s) => /\{(env|file):[^}]+\}/.test(s) || URL.canParse(s), { message: "Invalid URL" })
const Tls = z.strictObject({ ca: z.string().optional(), insecure: z.boolean().optional() })
export const ModelSpec = z.strictObject({
name: z.string().optional().describe("A display name; the id is what is sent."),
family: z.string().optional().describe("Picks the prompt overlay: anthropic | gpt | gemini | local. Guessed from the id when absent."),
context: z.number().int().nonnegative().optional().describe("Total context window in tokens. 0 / absent = unknown: no percentage, no auto-compaction."),
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."),
top_p: z.number().min(0).max(1).optional().describe("Nucleus sampling; absent = the server's default."),
efforts: z.array(z.enum(EFFORTS)).optional().describe("The model's own effort vocabulary, in offering order."),
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("Default effort; `off` sends none."),
effort_style: z.enum(["top", "kwargs", "both"]).optional().describe("openai-chat only: where the effort goes. llama.cpp drops top-level, so `both` is the default."),
effort_map: z.partialRecord(z.enum(EFFORTS), z.number().int().positive()).optional().describe("anthropic/gemini: effort → thinking budget tokens."),
vision: z.boolean().optional().describe("The model reads images: @image files, pasted paths and view_image."),
tools: z.boolean().optional().describe("false: a model without tool calls — it can only talk."),
cache: z.boolean().optional().describe("anthropic: prompt caching breakpoints."),
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."),
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."),
notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."),
fallback: z.array(z.string()).optional().describe("Other models (connection/model), in order, to switch to when this one's server cannot be reached at all — never once a reply has begun."),
single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused."),
group: z.string().optional().describe("Models sharing one server that holds one model at a time (llama-swap), named by any string: a subagent on another model of the same group would unload this one, so it is refused. One connection's one_model_at_a_time is the same rule for all its models; this is for models one connection serves from several such servers (a LLeMbas instance)."),
})
export type ModelSpec = z.infer<typeof ModelSpec>
export const Connection = z.strictObject({
dialect: z.enum(DIALECTS).describe("The API it speaks: openai-chat (llama.cpp, vLLM, LM Studio, OpenAI…), responses (OpenAI Responses), anthropic, gemini, ollama (its native /api/chat)."),
base_url: Url.describe("Where the API is, e.g. https://api.openai.com/v1 or http://localhost:8080/v1."),
api_key: z.string().optional().describe("Never pasted: {env:NAME} or {file:~/path}. Or use key_cmd."),
key_cmd: z.string().optional().describe("A command whose output is the key, e.g. pass show openai."),
auth: z.enum(["bearer", "x-api-key", "x-goog-api-key", "none"]).optional().describe("How the key is sent. Default per dialect: anthropic x-api-key, gemini x-goog-api-key, others bearer."),
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for every request."),
body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body."),
tls: Tls.optional().describe("TLS for endpoints behind a private CA. `ca` is a PEM file added to the trusted roots; `insecure` skips verification entirely (last resort). The system store is always trusted."),
timeout: z.number().positive().optional().describe("Seconds of silence allowed: waiting for the response, then between two pieces of a streamed reply. Not a limit on how long a reply may take. Default 600."),
discover: z.boolean().optional().describe("Merge ids and context lengths from GET /models."),
unload_url: Url.optional().describe("llama-swap style: frees VRAM when switching away."),
one_model_at_a_time: z.boolean().optional().describe("The server holds one model at a time (llama-swap in front of one GPU): a subagent on another of its models would unload the session's, so it is refused. The session's own model may still be its own subagent."),
unload_method: z.enum(["GET", "POST"]).optional().describe("How unload_url is called (default POST)."),
quirks: z
.strictObject({
stream_usage: Tristate.optional().describe("Ask for token usage in the stream (stream_options); auto learns when a server refuses it."),
prompt_progress: Tristate.optional().describe("Ask how far the server is through reading the prompt (llama.cpp's return_progress), shown while it reads; auto learns when a server refuses it, and one that ignores it shows nothing."),
think_tags: Tristate.optional().describe("<think>…</think> in a reply is taken as reasoning; off leaves it as text."),
max_tokens_field: z.enum(["max_tokens", "max_completion_tokens"]).optional().describe("OpenAI reasoning models refuse `max_tokens`; most local servers only know it."),
})
.optional()
.describe("Server oddities; auto is right almost always."),
models: z.record(z.string(), ModelSpec).default({}).describe("The models to offer, by the id the server knows them by."),
})
export type Connection = z.infer<typeof Connection>
/** 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
* from the instance at every start. `lembas login` writes it. */
export const WebuiConnection = z.strictObject({
type: z.literal("webui").describe("A LLeMbas instance: its models, their settings, its voice and its web search."),
url: Url.describe("The instance's address, e.g. https://ai.example.org (no /v1)."),
api_key: z.string().optional().describe("This machine's token, as login wrote it: {file:~/.config/lembas/lembas/<name>.key}. Or key_cmd."),
key_cmd: z.string().optional(),
tls: Tls.optional().describe("For an instance behind a private CA: `ca`, the PEM file that signed its certificate."),
timeout: z.number().positive().optional().describe("Seconds of silence allowed in a reply, as for any connection (default 600)."),
quirks: Connection.shape.quirks,
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 const ConnectionsFile = z.strictObject({
connections: z
.record(z.string(), z.union([WebuiConnection, Connection]))
.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."),
})
export type ConnectionsFile = z.infer<typeof ConnectionsFile>
const ActionSchema = z.enum(ACTIONS)
/** `tool: action` or `tool: { pattern: action }` — order matters, last match wins. */
export const PermissionConfig = z.record(z.string(), z.union([ActionSchema, z.record(z.string(), ActionSchema)]))
export type PermissionConfig = z.infer<typeof PermissionConfig>
const Section = z.record(z.string(), z.unknown())
const VoiceEndpoint = {
base_url: z.string().optional(),
api_key: z.string().optional(),
key_cmd: z.string().optional(),
tls: Tls.optional(),
timeout: z.number().positive().optional(),
}
/** `provider: webui`: through a LLeMbas instance's own speech servers, with the account's
* voice, speed and language. */
const WebuiVoice = {
connection: z.string().optional().describe("webui: which webui connection (default: the only one there is)."),
}
const SttEndpoint = z.strictObject({
provider: z.enum(["openai", "whispercpp", "webui"]).optional().describe("openai: POST {base_url}/audio/transcriptions (OpenAI, whisper.cpp's server with its OpenAI path, faster-whisper servers). whispercpp: POST {base_url}/inference (whisper.cpp's own). webui: the LLeMbas instance's."),
...VoiceEndpoint,
...WebuiVoice,
model: z.string().optional(),
language: z.string().optional(),
prompt: z.string().optional().describe("Words it should expect: names, jargon."),
})
const TtsEndpoint = z.strictObject({
provider: z.enum(["openai", "piper-http", "piper-cli", "webui"]).optional().describe("openai: POST {base_url}/audio/speech (OpenAI, Kokoro-FastAPI). piper-http: POST {base_url} with {text} (piper's http_server). piper-cli: the piper program. webui: the LLeMbas instance's, in the account's voice."),
...VoiceEndpoint,
...WebuiVoice,
model: z.string().optional(),
voice: z.string().optional(),
speed: z.number().min(0.25).max(4).optional(),
command: z.string().optional().describe("piper-cli: the program (default piper) and the voice model file (.onnx)."),
model_path: z.string().optional(),
})
/** Voice: speech to text, text to speech, and how to record and play. Global only — it
* carries endpoints and keys, like a connection. */
export const Voice = z.strictObject({
stt: SttEndpoint.extend({ fallback: SttEndpoint.optional().describe("Used when the provider above cannot be reached — for webui, when the instance is down. Same fields.") }).optional(),
tts: TtsEndpoint.extend({ fallback: TtsEndpoint.optional().describe("Used when the provider above cannot be reached — for webui, when the instance is down. Same fields.") }).optional(),
record_key: z.string().optional().describe("The key that starts and stops recording (default ctrl+t)."),
record_mode: z.enum(["toggle", "hold"]).optional().describe("toggle (default): press to start, press again (or silence) to stop. hold: also stops when the key is let go, in terminals that report it (the kitty keyboard protocol)."),
submit: z.enum(["draft", "send"]).optional().describe("What happens to what you said: draft (default) puts it in the prompt to check; send sends it."),
speak: z.boolean().optional().describe("Speak every reply (default false; /speak toggles it)."),
silence_seconds: z.number().positive().optional().describe("Seconds of silence after speech that end a recording (default 3), and the longest one (120)."),
max_seconds: z.number().positive().optional(),
silence_threshold: z.number().positive().optional().describe("Loudness (RMS of 16-bit samples) below which it counts as silence (default 200)."),
recorder: z.enum(["auto", "arecord", "pw-record", "parec", "sox", "ffmpeg"]).optional().describe("auto, or a program: arecord, pw-record, parec, sox, ffmpeg. `recorder_command` instead: any command printing raw 16 kHz mono signed 16-bit little-endian audio to stdout."),
recorder_command: z.array(z.string()).min(1).optional(),
player: z.enum(["auto", "pw-play", "paplay", "aplay", "ffplay", "mpv", "afplay"]).optional().describe("auto, or a program: pw-play, paplay, aplay, ffplay, mpv, afplay. `player_command` instead: any command; the WAV file's path is added at the end."),
player_command: z.array(z.string()).min(1).optional(),
})
export type Voice = z.infer<typeof Voice>
/** One MCP server: `command` (stdio, a local process) or `url` (streamable HTTP, SSE fallback). */
export const McpServer = z
.strictObject({
command: z.union([z.string(), z.array(z.string()).min(1)]).optional().describe("A local server: the program (and its arguments, as a list). Either this or url."),
args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional().describe("Environment for the local server; {env:}/{file:} work here."),
cwd: z.string().optional(),
url: z.string().optional().describe("A remote server: its streamable HTTP (or SSE) address. Either this or command."),
headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers, e.g. Authorization: Bearer {env:TOKEN}."),
transport: z.enum(["http", "sse"]).optional().describe("http (default: streamable HTTP, falling back to SSE) or sse."),
oauth: z
.union([
z.literal(false),
z.strictObject({ client_id: z.string().optional(), client_secret: z.string().optional(), scope: z.string().optional(), callback_port: z.number().int().positive().optional() }),
])
.optional()
.describe("OAuth for a remote server: on by default when the server asks for it; false turns it off."),
tls: Tls.optional(),
enabled: z.boolean().optional().describe("false: configured but not started (/mcp on starts it)."),
timeout: z.number().positive().optional().describe("Seconds: one tool call (default 120), connecting (default 30)."),
connect_timeout: z.number().positive().optional(),
tools: z.strictObject({ include: z.array(z.string()).optional(), exclude: z.array(z.string()).optional() }).optional().describe("Which of its tools to offer: include wins; names or globs (`create_*`)."),
prompts: z.boolean().optional().describe("Offer its prompts as /server:prompt commands (default true)."),
resources: z.boolean().optional().describe("Offer list/read tools for its resources, if it has any (default true)."),
instructions: z.boolean().optional().describe("Put the server's own instructions into the system prompt (default true)."),
})
.refine((s) => (s.command === undefined) !== (s.url === undefined), { message: "give either command (a local server) or url (a remote one)" })
export type McpServer = z.infer<typeof McpServer>
export const UPDATE_CHANNELS = ["stable", "beta"] as const
/** Where updates come from and how they are checked. Global only: a project never decides
* what binary runs. */
export const Update = z.strictObject({
channel: z.enum(UPDATE_CHANNELS).optional().describe("stable (default): releases vX.Y.Z only. beta: also vX.Y.Z-beta.N — whichever is newest."),
auto: z.enum(["install", "notify", "off"]).optional().describe("On start: install (default) — fetch, verify and install a newer release, then say so; notify — only say one is out; off — never look."),
source: z
.strictObject({
type: z.enum(["github", "gitea", "static"]).describe("github or gitea (Forgejo too): their releases API. static: any web directory with files `stable` and `beta` naming a tag, and the assets under <tag>/."),
url: Url.optional().describe("gitea: the forge (https://git.example.org). github: the API (default https://api.github.com). static: the directory."),
repo: z.string().regex(/^(?!\.\.?\/)[\w.-]+\/(?!\.\.?$)[\w.-]+$/).optional().describe("owner/name, for github and gitea."),
token: z.string().optional().describe("For a private repository: {env:NAME} or {file:~/path}, never pasted. Or key_cmd."),
key_cmd: z.string().optional(),
tls: Tls.optional(),
})
.optional()
.describe("Default: the public LLeMbas CLI releases on GitHub."),
public_key: z.union([z.string(), z.array(z.string())]).optional().describe("The ssh-ed25519 key(s) a release's SHA256SUMS.sig must be signed with — instead of LLeMbas CLI's own. For your own builds."),
verify: z.enum(["signature", "checksum"]).optional().describe("signature (default): SHA256SUMS must carry a valid signature. checksum: checksums only — for a source of your own that does not sign."),
})
export type Update = z.infer<typeof Update>
export const Config = z.strictObject({
model: z.string().optional().describe("The model a session starts with: `connection/model`, a connection from connections.yaml and one of its models."),
small_model: z.string().optional().describe("`connection/model` for small jobs — session titles, commit messages. Default: the session's model. On a server that holds one model at a time (llama-swap), another model here swaps."),
titles: z.enum(["model", "prompt"]).optional().describe("Session titles: model (default) — named from the first prompt, then by the model after its first reply; prompt — only from the first prompt, no extra request."),
mode: z.enum(MODES).optional().describe("The permission mode a session starts in (default manual)."),
theme: z.string().optional().describe("Colours: lembas (default), lembas-light, moria, shire, hermes, mono, or a themes/<name>.yaml of your own."),
theme_background: z.enum(["theme", "terminal"]).optional().describe("theme (default): the theme paints the whole screen; terminal: the terminal's own background shows, and the theme colours only text, panels and accents."),
skin: z.string().optional().describe("The old name of `theme`; still read when `theme` is not set."),
busy_input: z.enum(["steer", "queue"]).optional().describe("A message sent while the agent works: steer (default) — it goes in at the next step, after the running tools finish, and the agent carries on with it; queue — it is sent as the next prompt once the task is done."),
mouse: z.boolean().optional().describe("false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true."),
icons: z.enum(["emoji", "plain"]).optional().describe("emoji (default), or plain: one-column symbols for a terminal font that has no emoji."),
personality: z
.enum(PERSONALITIES)
.or(z.literal(""))
.optional()
.describe("How the agent talks: concise, pragmatic, optimistic, funny, formal, socratic, custom (personality_custom's text), or empty for none (the default). The same presets as the LLeMbas web UI; appended last to the system prompt, under \"## Personality\"."),
personality_custom: z.string().max(1500).optional().describe("The personality, in your own words: used when personality is custom (at most 1500 characters)."),
memory: z
.strictObject({
enabled: z.boolean().optional().describe("false: no memory tool and nothing remembered in the prompt."),
memory_chars: z.number().int().positive().optional().describe("Size limits in characters: memory 2200, user 1375 by default."),
user_chars: z.number().int().positive().optional(),
})
.optional()
.describe("Persistent memory (MEMORY.md, USER.md in ~/.config/lembas/memory/)."),
skills: z
.strictObject({
external_dirs: z.array(z.string()).optional().describe("More directories of skills, read-only; a skill of the same name elsewhere wins."),
disabled: z.array(z.string()).optional().describe("Skill names not to offer."),
})
.optional(),
permission: PermissionConfig.optional().describe("Rules per tool: `tool: allow|ask|deny` or `tool: { pattern: action }`; the last match wins."),
hardline_extra: z.array(z.string()).optional().describe("Global config only: extra never-allowed bash regexes."),
hardline_disable: z.array(z.string()).optional().describe("Global config only: ids of built-in hardline rules to switch off."),
limits: z
.strictObject({
steps: z.number().int().positive().optional().describe("Most model steps in one prompt (default 200) — a runaway backstop, not a budget."),
bash_timeout: z.number().positive().optional().describe("Seconds a bash command may run (default 120)."),
wall_seconds: z.number().positive().optional().describe("Budget for one prompt: seconds of work, not counting time spent waiting for you. Past it the model answers from what it has. Unset: none."),
output_bytes: z.number().int().positive().optional().describe("Budget for one prompt: bytes of tool output read. Unset: none."),
completion_tokens: z.number().int().positive().optional().describe("Budget for one prompt: tokens the model writes. Unset: none."),
})
.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."),
instructions: z.string().max(4000).optional().describe("Custom instructions: how you want to be helped, in your own words (at most 4000 characters). Appended near the end of the system prompt, under \"## How the person you are talking to wants to be helped\" — as in the LLeMbas web UI. (A list of files here, from an older config, is read as instruction_files.)"),
instruction_files: z.array(z.string()).optional().describe("More instruction files for the system prompt, besides AGENTS.md / CLAUDE.md."),
search: z
.strictObject({
order: z.array(z.enum(["webui", "searxng", "firecrawl", "ddg"])).optional().describe("Providers to try, in order; the first that answers wins. Default: those configured (webui first), then ddg. webui: the LLeMbas instance's own search."),
max_results: z.number().int().min(1).max(30).optional().describe("Results per search (default 8)."),
searxng: z.strictObject({ base_url: Url, api_key: z.string().optional(), tls: Tls.optional(), categories: z.string().optional(), language: z.string().optional() }).optional(),
firecrawl: z.strictObject({ base_url: Url.optional(), api_key: z.string().optional(), tls: Tls.optional() }).optional().describe("API (api.firecrawl.dev, with a key) or self-hosted (its own URL, no key needed)."),
ddg: z.strictObject({ region: z.string().optional(), safe: z.enum(["strict", "moderate", "off"]).optional() }).optional(),
webui: z.strictObject({ connection: z.string().optional().describe("Which webui connection (default: the only one there is).") }).optional().describe("Search through a LLeMbas instance: its provider, its settings, its keys."),
fetch: z.enum(["local", "firecrawl", "webui"]).optional().describe("How web_fetch reads a page: here, through Firecrawl's scrape, or through the LLeMbas instance."),
})
.optional(),
voice: Voice.optional().describe("Voice in and out. Global only — it carries endpoints and keys, like a connection."),
mcp: z.record(z.string(), McpServer).optional().describe("MCP servers by name. `{env:}`/`{file:}` are filled in per server (a missing one disables that server only). A project's servers count only once the project is trusted."),
knowledge: z.array(z.string()).optional().describe("The knowledge bases searched (lembas kb); absent: all of them. A project's own config narrows it to what that project needs."),
embedding: z.string().optional().describe("Global config only. connection/model of an embedding model (OpenAI-shaped /embeddings, or Ollama): knowledge bases are then searched by meaning as well as by words. lembas kb reindex after changing it."),
effort: z.union([z.enum(EFFORTS), z.literal("off")]).optional().describe("The effort a session starts with, when the model takes it; otherwise the model's own default."),
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
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)."),
remote: z
.strictObject({
enabled: z.boolean().optional().describe("Accept work from a LLeMbas instance this machine is logged in to (lembas service). Default false."),
roots: z.array(z.string()).optional().describe("The directories a remote session may be opened in, and anything under them. Empty: none."),
max_mode: z.enum(MODES).optional().describe("The most permissive mode a remote session may run in (default edit). auto only when set here."),
approval_timeout: z.number().int().positive().optional().describe("Seconds an approval waits for an answer from the web before it counts as a denial (default 600)."),
require_trust: z.boolean().optional().describe("Only projects trusted on this machine (default true)."),
terminal: z.boolean().optional().describe("Let the web UI open a terminal here (LLeMbas's terminal panel on a chat on this device): a login shell as this user, in a directory remote sessions may use. Default false."),
terminal_integration: z.boolean().optional().describe("Shell integration in those terminals (bash, zsh, fish): after your own startup files, the shell marks each prompt, command and exit status (OSC 133) and its directory (OSC 7), so the web UI shows the directory, a mark beside each finished command, and can jump between commands. false: the shell starts as it is. Default true."),
})
.optional()
.describe("Global config only. What a LLeMbas instance may ask of this machine over the link: the device decides, never the server."),
})
export type Config = z.infer<typeof Config>
+188
View File
@@ -0,0 +1,188 @@
// What can be set while LLeMbas CLI runs, and how: one list shared by /settings, `lembas
// config get|set|list` and the agent's settings tool. A setting has a scope —
// session this session only, nothing written;
// global ~/.config/lembas/config.yaml (its comments kept);
// project the project's .agent/config.yaml (a trusted project only).
// Anything not listed here is not settable this way: permission rules, the hardline, MCP servers,
// connections and where updates come from are edited in the files by hand, never by the agent.
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { parse, parseDocument } from "yaml"
import { z } from "zod"
import { paths } from "./paths.ts"
import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts"
export class SettingError extends Error {}
export type Scope = "session" | "global" | "project"
export const SCOPES: Scope[] = ["session", "global", "project"]
export type Source = "default" | "global" | "project" | "session"
export interface SettingDef {
key: string
/** model: a configured `connection/model`; effort: one the model takes, or off; list: comma-separated. */
kind: "enum" | "model" | "effort" | "number" | "boolean" | "list" | "string"
values?: readonly string[]
/** The agent may change it (through the settings tool, with approval). */
agent: boolean
scopes: Scope[]
/** Takes effect from the next start (/reload), not at once. */
reload?: boolean
/** What it is when nothing sets it. */
fallback?: string
}
const ALL: Scope[] = ["session", "global", "project"]
const USER: Scope[] = ["session", "global"]
export const SETTINGS: SettingDef[] = [
{ key: "model", kind: "model", agent: true, scopes: ALL },
{ key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's default" },
{ 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: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
{ key: "embedding", kind: "string", agent: false, scopes: ["global"], reload: true, fallback: "none (words only)" },
{ key: "titles", kind: "enum", values: ["model", "prompt"], agent: true, scopes: ALL, fallback: "model" },
// The person's own words, put last in the system prompt as theirs: never the agent's to write, and
// the text never a project's (load.ts GLOBAL_ONLY) — a preset's name may be.
{ key: "personality", kind: "enum", values: ["none", ...PERSONALITIES], agent: false, scopes: ALL, fallback: "none" },
{ key: "personality_custom", kind: "string", agent: false, scopes: USER, fallback: "none" },
{ key: "instructions", kind: "string", agent: false, scopes: USER, fallback: "none" },
{ key: "theme", kind: "string", agent: true, scopes: USER, fallback: "lembas" },
{ key: "theme_background", kind: "enum", values: ["theme", "terminal"], agent: true, scopes: USER, fallback: "theme" },
{ key: "busy_input", kind: "enum", values: ["steer", "queue"], agent: true, scopes: USER, fallback: "steer" },
{ key: "icons", kind: "enum", values: ["emoji", "plain"], agent: true, scopes: USER, fallback: "emoji" },
{ key: "mouse", kind: "boolean", agent: true, scopes: ["global"], reload: true, fallback: "true" },
{ key: "compaction.auto_at", kind: "number", agent: true, scopes: ALL, fallback: "0.85" },
{ key: "compaction.prune", kind: "boolean", agent: true, scopes: ALL, fallback: "true" },
{ key: "search.order", kind: "list", values: ["searxng", "firecrawl", "ddg"], agent: true, scopes: ALL, fallback: "those configured, then ddg" },
{ key: "search.max_results", kind: "number", agent: true, scopes: ALL, fallback: "8" },
{ key: "memory.enabled", kind: "boolean", agent: true, scopes: ALL, reload: true, fallback: "true" },
{ key: "limits.steps", kind: "number", agent: true, scopes: ALL, fallback: "200" },
{ key: "limits.bash_timeout", kind: "number", agent: true, scopes: ALL, fallback: "120" },
{ key: "limits.wall_seconds", kind: "number", agent: true, scopes: ALL, fallback: "none" },
{ key: "limits.output_bytes", kind: "number", agent: true, scopes: ALL, fallback: "none" },
{ key: "limits.completion_tokens", kind: "number", agent: true, scopes: ALL, fallback: "none" },
{ key: "question.unattended", kind: "enum", values: ["recommended", "first", "fail"], agent: true, scopes: ALL, fallback: "recommended" },
// Never the agent's: what binary runs, and its own gate.
{ key: "update.channel", kind: "enum", values: UPDATE_CHANNELS, agent: false, scopes: ["global"], fallback: "stable" },
{ key: "update.auto", kind: "enum", values: ["install", "notify", "off"], agent: false, scopes: ["global"], fallback: "install" },
{ key: "settings_tool", kind: "enum", values: ["ask", "allow", "off"], agent: false, scopes: ["global"], reload: true, fallback: "ask" },
]
export const findSetting = (key: string) => SETTINGS.find((s) => s.key === key)
/** The schema for one key of config.yaml, walked down through objects and optionals. */
function schemaAt(path: string[]): z.ZodType | undefined {
let s: z.ZodType | undefined = Config
for (const part of path) {
while (s instanceof z.ZodOptional || s instanceof z.ZodDefault) s = s.unwrap() as z.ZodType
if (!(s instanceof z.ZodObject)) return undefined
s = (s.shape as Record<string, z.ZodType>)[part]
}
return s
}
export function describe(def: SettingDef): string {
return schemaAt(def.key.split("."))?.description ?? ""
}
/** A value as typed (a string from the CLI or the dialog) or sent (JSON from the model), checked
* against the config schema. Throws with a message meant for whoever set it. */
export function parseValue(def: SettingDef, raw: unknown): unknown {
// A placeholder would be filled in when the file is next read — the environment or a file put
// into a setting the agent can read back. Only the user writes those, by hand.
if (JSON.stringify(raw ?? "").match(/\{(env|file):/)) throw new SettingError(`${def.key}: {env:} and {file:} are written into config.yaml by hand, not set here`)
let v = raw
if (typeof raw === "string") {
const t = raw.trim()
if (def.kind === "number") v = Number(t)
else if (def.kind === "boolean") v = t === "true" || t === "on" || t === "yes" ? true : t === "false" || t === "off" || t === "no" ? false : t
else if (def.kind === "list") v = t.split(/[\s,]+/).filter(Boolean)
else v = t
}
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.
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`)
const schema = schemaAt(def.key.split("."))
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")}`)
return r.data
}
export function show(v: unknown): string {
if (v === undefined) return ""
if (Array.isArray(v)) return v.join(", ")
return String(v)
}
function getIn(o: unknown, path: string[]): unknown {
let cur = o
for (const p of path) {
if (!cur || typeof cur !== "object") return undefined
cur = (cur as Record<string, unknown>)[p]
}
return cur
}
/** A config file as written (no substitution, no validation): where a value is set. */
export function readRaw(file: string): Record<string, unknown> {
try {
const v = existsSync(file) ? parse(readFileSync(file, "utf8")) : undefined
return v && typeof v === "object" ? (v as Record<string, unknown>) : {}
} catch {
return {}
}
}
export const globalConfigFile = () => join(paths.config, "config.yaml")
/** Set one key in a YAML file, keeping the rest of it (comments, order, layout) as it was. Returns
* what else the write had to change to keep the file meaning what it did (said to whoever set it). */
export function writeKey(file: string, path: string[], value: unknown, write: (file: string, text: string) => void): string[] {
const doc = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "")
if (!doc.contents) doc.contents = doc.createNode({}) as never
const notes: string[] = []
// `instructions` was once the list of instruction files, and is the custom-instructions
// text now; loading reads an old list as instruction_files, but only in memory. Writing the text
// over the list would quietly throw the file names away, so the list moves to instruction_files
// in the same write (after any already there) and the text takes its place.
if (path.length === 1 && path[0] === "instructions") {
const moved = legacyInstructionFiles(doc.toJS()?.instructions)
if (moved) {
const had = doc.getIn(["instruction_files"])
const before = doc.toJS()?.instruction_files
const kept = Array.isArray(before) ? before.map(String) : typeof before === "string" && before.trim() ? [before] : []
doc.setIn(["instruction_files"], [...kept, ...moved.filter((f) => !kept.includes(f))])
notes.push(`the list of files that was under instructions (${moved.join(", ")}) is instruction_files now${had !== undefined ? ", after the ones already there" : ""}`)
}
}
doc.setIn(path, value)
write(file, doc.toString())
return notes
}
/** A list under `instructions`: the instruction files of an older config. */
export function legacyInstructionFiles(v: unknown): string[] | undefined {
return Array.isArray(v) ? v.map(String) : undefined
}
export interface Entry {
def: SettingDef
value: unknown
source: Source
/** Something to know about the value: where an old config's list of files went. */
note?: string
}
/** Where a setting's value comes from, given the files as written and the session's changes. */
export function sourceOf(key: string, session: Map<string, unknown>, project: Record<string, unknown> | undefined, global: Record<string, unknown>): { value: unknown; source: Source } {
const path = key.split(".")
if (session.has(key)) return { value: session.get(key), source: "session" }
const p = project ? getIn(project, path) : undefined
if (p !== undefined) return { value: p, source: "project" }
const g = getIn(global, path)
if (g !== undefined) return { value: g, source: "global" }
return { value: undefined, source: "default" }
}
+47
View File
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs"
import { expandHome } from "./paths.ts"
// `{env:NAME}` and `{file:path}` inside any string value of a config file.
// A missing variable or file is an error, not an empty string: an empty API key
// fails later and far from the cause.
const TOKEN = /\{(env|file):([^}]+)\}/g
export class SubstitutionError extends Error {}
export function substitute(value: string, where: string): string {
return value.replace(TOKEN, (_, kind: string, arg: string) => {
const name = arg.trim()
if (kind === "env") {
const v = process.env[name]
if (v === undefined) throw new SubstitutionError(`${where}: environment variable ${name} is not set`)
return v
}
try {
return readFileSync(expandHome(name), "utf8").trim()
} catch {
throw new SubstitutionError(`${where}: cannot read file ${name}`)
}
})
}
export function substituteDeep<T>(value: T, where = "config"): T {
if (typeof value === "string") return substitute(value, where) as T
if (Array.isArray(value)) return value.map((v, i) => substituteDeep(v, `${where}[${i}]`)) as T
if (value && typeof value === "object") {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = substituteDeep(v, `${where}.${k}`)
return out as T
}
return value
}
// `key_cmd`: run a command and use its trimmed stdout, e.g. `pass show api/anthropic`.
export function runKeyCmd(cmd: string, where: string): string {
const res = Bun.spawnSync(["sh", "-c", cmd], { stdout: "pipe", stderr: "pipe" })
if (res.exitCode !== 0) {
throw new SubstitutionError(`${where}: key_cmd exited ${res.exitCode}: ${res.stderr.toString().trim()}`)
}
const out = res.stdout.toString().trim()
if (!out) throw new SubstitutionError(`${where}: key_cmd printed nothing`)
return out
}
+15
View File
@@ -0,0 +1,15 @@
// Settings the TUI remembers (/personality, /theme) go into the global config.yaml, keeping the
// comments and layout of whatever the user wrote there.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { parseDocument } from "yaml"
import { paths } from "./paths.ts"
export function setGlobalConfig(path: string[], value: unknown) {
const file = join(paths.config, "config.yaml")
mkdirSync(paths.config, { recursive: true })
const doc = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "")
if (!doc.contents) doc.contents = doc.createNode({}) as never
doc.setIn(path, value)
writeFileSync(file, doc.toString())
}
+59
View File
@@ -0,0 +1,59 @@
// Checks of this machine that `lembas config check` reports.
//
// A LLeMbas server installed with pip may bring a console script called `lembas` too (the server
// itself runs as `lembas-server`). Installed into a directory
// that comes first on PATH, it is what `lembas` runs — and the person typing it gets a Python
// traceback or a web server instead of this program. install.sh says so when it installs; this says
// so whenever the configuration is checked.
import { accessSync, constants, openSync, readSync, closeSync, realpathSync, statSync } from "node:fs"
import { delimiter, dirname, join } from "node:path"
/** The first `lembas` on `path`, the way a shell finds it. */
export function firstOnPath(name: string, path = process.env.PATH ?? ""): string | undefined {
for (const dir of path.split(delimiter)) {
if (!dir) continue
const f = join(dir, name)
try {
if (!statSync(f).isFile()) continue
accessSync(f, constants.X_OK)
return f
} catch {}
}
return undefined
}
function real(p: string): string {
try {
return realpathSync(p)
} catch {
return p
}
}
/** Whether a file is a Python script (its first line a `#!` naming python). */
export function isPythonScript(file: string): boolean {
try {
const fd = openSync(real(file), "r")
const buf = Buffer.alloc(300)
const n = readSync(fd, buf, 0, 300, 0)
closeSync(fd)
const first = buf.subarray(0, n).toString("utf8").split("\n")[0] ?? ""
return first.startsWith("#!") && /python/.test(first)
} catch {
return false
}
}
/** Why `lembas` typed in a shell would not run this program, or undefined when it would (or this
* cannot tell: run from source, nothing on PATH). `self`: this binary — undefined from source, where
* only a Python script is recognised as the wrong one. */
export function lembasShadowed(o: { path?: string; self?: string } = {}): string | undefined {
const self = "self" in o ? o.self : /(^|\/)bun$/.test(process.execPath) ? undefined : process.execPath
const found = firstOnPath("lembas", o.path)
if (!found) return undefined
if (self && real(found) === real(self)) return undefined
const python = isPythonScript(found)
if (!python && !self) return undefined
const what = python ? "a Python console script — most likely the LLeMbas server's own `lembas`" : "another program"
return `\`lembas\` runs ${found}, ${what}, not this LLeMbas CLI${self ? ` (${self})` : ""}. Put ${self ? dirname(self) : "the directory LLeMbas CLI is installed in"} before ${dirname(found)} in PATH, or remove that one — the server itself runs as lembas-server.`
}
+9
View File
@@ -0,0 +1,9 @@
/** Time as the eye reads it: 8.4s, 1m 05s, 2h 03m. `whole`: no tenths under a minute (8s) — for a
* clock that ticks while you watch. */
export function duration(ms: number, whole = false): string {
const s = Math.max(0, ms) / 1000
if (s < 60) return whole ? `${Math.floor(s)}s` : `${s.toFixed(1)}s`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ${String(Math.floor(s % 60)).padStart(2, "0")}s`
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`
}
+61
View File
@@ -0,0 +1,61 @@
// /branch: list, switch to, make and delete local branches. Plain git, and git's own refusals
// stand: a switch that would overwrite uncommitted work, or deleting a branch that is not merged,
// is refused by git and the reason is shown — nothing here forces either.
import { git } from "./run.ts"
export interface Branch {
name: string
current: boolean
/** Last commit: short hash and subject. */
last: string
/** Unix ms of the last commit. */
at: number
upstream?: string
}
export function branches(root: string): Branch[] {
const r = git(root, ["for-each-ref", "--sort=-committerdate", "--format=%(HEAD)%00%(refname:short)%00%(objectname:short) %(contents:subject)%00%(committerdate:unix)%00%(upstream:short)", "refs/heads/"])
if (r.code !== 0 || !r.out) return []
return r.out.split("\n").map((l) => {
const [head = "", name = "", last = "", at = "0", upstream = ""] = l.split("\0")
return { name, current: head === "*", last, at: Number(at) * 1000, upstream: upstream || undefined }
})
}
export function currentBranch(root: string): string | undefined {
const r = git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])
return r.code === 0 ? r.out : undefined
}
/** Why a name cannot be a branch, or undefined when it can. */
export function badBranchName(root: string, name: string): string | undefined {
if (!name) return "no name given"
if (name.startsWith("-")) return "a branch name cannot start with -"
return git(root, ["check-ref-format", "--branch", name]).code === 0 ? undefined : `"${name}" is not a valid branch name`
}
export interface BranchResult {
ok: boolean
text: string
}
/** Switch to a branch, making it from HEAD when it does not exist yet. Uncommitted changes go
* along when git can carry them; when it cannot, git refuses and nothing changes. */
export function switchBranch(root: string, name: string): BranchResult {
const bad = badBranchName(root, name)
if (bad) return { ok: false, text: bad }
if (currentBranch(root) === name) return { ok: true, text: `already on ${name}` }
const exists = git(root, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]).code === 0
const r = git(root, exists ? ["switch", "--quiet", name] : ["switch", "--quiet", "-c", name])
if (r.code !== 0) return { ok: false, text: r.err || r.out }
return { ok: true, text: exists ? `switched to ${name}` : `made ${name} from ${git(root, ["log", "-1", "--format=%h %s"]).out || "an empty history"} and switched to it` }
}
/** `git branch -d`: only a branch whose work is merged. The current branch cannot be deleted. */
export function deleteBranch(root: string, name: string): BranchResult {
if (currentBranch(root) === name) return { ok: false, text: `${name} is the current branch — switch to another first` }
const r = git(root, ["branch", "-d", "--", name])
if (r.code === 0) return { ok: true, text: `deleted ${name}` }
if (/not fully merged/.test(r.err)) return { ok: false, text: `${name} has work that is not merged anywhere, so it was kept. If it really should go: git branch -D ${name}` }
return { ok: false, text: r.err || r.out }
}
+79
View File
@@ -0,0 +1,79 @@
// /commit: what to commit, a drafted message, and the commit itself. Files are always staged by
// name — never `git add -A` — so nothing the session did not touch rides along.
import { readFileSync } from "node:fs"
import { join } from "node:path"
import type { Client } from "../provider/types.ts"
import { git } from "./run.ts"
export interface CommitPlan {
files: string[]
/** true: the user had already staged these; commit exactly the index. */
staged: boolean
}
/** The index if anything is staged; otherwise the session's changed files that git sees as changed. */
export function commitPlan(root: string, touched: string[], all = false): CommitPlan {
const staged = git(root, ["diff", "--cached", "--name-only", "-z"]).out.split("\0").filter(Boolean)
if (staged.length && !all) return { files: staged, staged: true }
const status = git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]).out.split("\0").filter(Boolean)
const changed = status.map((l) => l.slice(3))
const files = all ? changed : changed.filter((f) => touched.includes(f))
return { files, staged: false }
}
const PROMPT = `Write a git commit message for the change below.
- First line: a summary under 72 characters, imperative mood, in the style of the recent commits shown.
- Then, only if the change needs explaining, a blank line and a short body saying what changed and why — not a list of files.
- Reply with the message only: no quotes, no code fences, no preamble.
Recent commits, for style:
{{recent}}
The change:
{{diff}}`
export async function draftMessage(client: Client, root: string, plan: CommitPlan, signal?: AbortSignal): Promise<string> {
const recent = git(root, ["log", "-10", "--format=%s"]).out || "(none yet)"
const diff = plan.staged
? git(root, ["diff", "--cached", "--no-color", "--stat", "-p"]).out
: [
git(root, ["diff", "--no-color", "--stat", "-p", "HEAD", "--", ...plan.files]).out ||
git(root, ["diff", "--no-color", "-p", "--", ...plan.files]).out,
...untrackedPreview(root, plan.files),
].join("\n")
const prompt = PROMPT.replace("{{recent}}", () => recent).replace("{{diff}}", () => diff.slice(0, 24_000))
let text = ""
for await (const ev of client.stream({ system: "", messages: [{ role: "user", parts: [{ type: "text", text: prompt }] }], tools: [], effort: null, signal }))
if (ev.type === "finish") text = ev.message.parts.map((p) => (p.type === "text" ? p.text : "")).join("")
return cleanMessage(text)
}
/** New files have no diff against HEAD; show the model their first lines instead. */
function untrackedPreview(root: string, files: string[]): string[] {
const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...files]).out.split("\0").filter(Boolean)
return untracked.map((f) => {
let head = ""
try {
head = readFileSync(join(root, f), "utf8").split("\n").slice(0, 40).join("\n")
} catch {}
return `new file ${f}:\n${head}`
})
}
export function cleanMessage(text: string): string {
let t = text.trim().replace(/^```\w*\n?/, "").replace(/\n?```$/, "").trim()
t = t.replace(/^(commit message|message):\s*/i, "").replace(/^["'](.*)["']$/s, "$1")
return t
}
export function commit(root: string, plan: CommitPlan, message: string): { ok: boolean; text: string } {
if (!plan.staged && plan.files.length) {
const add = git(root, ["add", "--", ...plan.files])
if (add.code !== 0) return { ok: false, text: add.err }
}
const r = git(root, ["commit", "-q", "-F", "-"], { input: message })
if (r.code !== 0) return { ok: false, text: r.err || r.out }
const head = git(root, ["log", "-1", "--format=%h %s"]).out
return { ok: true, text: head }
}
+228
View File
@@ -0,0 +1,228 @@
// /release: bump the version where the project keeps it, move CHANGELOG's Unreleased section
// under the new version, commit both files by name, and make an annotated tag — signed when git
// is set up to sign — whose message is that section. Nothing is pushed unless asked.
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
import { basename, join } from "node:path"
import { git } from "./run.ts"
export interface VersionFile {
file: string
version: string
/** The file's text with the version replaced. */
with(next: string): string
/** Other files that repeat the version and must move with it (package-lock.json). */
also?: { file: string; with(next: string): string }[]
}
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
const VERSIONISH = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?`
/** Replace the first match of `re` (whose group 2 is the version) in `text`. */
const swap = (text: string, re: RegExp, next: string) => text.replace(re, (_m, a: string, _v: string, b: string) => `${a}${next}${b}`)
function tomlVersion(text: string, table: string): string | undefined {
const sec = new RegExp(`^\\[${table.replace(".", "\\.")}\\]\\s*$([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, "m").exec(text)
return sec ? /^version\s*=\s*["']([^"']+)["']/m.exec(sec[1]!)?.[1] : undefined
}
function tomlWith(text: string, table: string, next: string): string {
const re = new RegExp(`(^\\[${table.replace(".", "\\.")}\\]\\s*$[\\s\\S]*?^version\\s*=\\s*["'])([^"']+)(["'])`, "m")
return swap(text, re, next)
}
/** package.json-style: the top-level "version" only (a nested one is someone else's), rewritten
* with the file's own indentation. */
function jsonVersion(text: string): { version: string; with(next: string): string } | undefined {
let j: any
try {
j = JSON.parse(text)
} catch {
return undefined
}
if (typeof j?.version !== "string") return undefined
const indent = /^\{\s*\n([ \t]+)"/.exec(text)?.[1] ?? " "
return {
version: j.version,
with: (next) => {
const o = JSON.parse(text)
o.version = next
// package-lock.json repeats it for the root package
if (o.packages?.[""] && typeof o.packages[""].version === "string") o.packages[""].version = next
return JSON.stringify(o, null, indent) + (text.endsWith("\n") ? "\n" : "")
},
}
}
/** Where the project's version lives: the first of package.json, pyproject.toml, Cargo.toml, a
* package's __init__.py, VERSION. The tree rule: one source of the version. */
export function findVersion(root: string): VersionFile | undefined {
const read = (f: string) => (existsSync(join(root, f)) ? readFileSync(join(root, f), "utf8") : undefined)
const pkg = read("package.json")
const pv = pkg ? jsonVersion(pkg) : undefined
if (pv) {
const lock = read("package-lock.json")
const lv = lock ? jsonVersion(lock) : undefined
return { file: "package.json", version: pv.version, with: pv.with, also: lv && lv.version === pv.version ? [{ file: "package-lock.json", with: lv.with }] : [] }
}
const py = read("pyproject.toml")
if (py) {
for (const t of ["project", "tool.poetry"]) {
const v = tomlVersion(py, t)
if (v) return { file: "pyproject.toml", version: v, with: (n) => tomlWith(py, t, n) }
}
}
const cargo = read("Cargo.toml")
if (cargo) {
for (const t of ["package", "workspace.package"]) {
const v = tomlVersion(cargo, t)
if (v) return { file: "Cargo.toml", version: v, with: (n) => tomlWith(cargo, t, n) }
}
}
for (const base of [".", "src"]) {
const dir = join(root, base)
if (!existsSync(dir)) continue
for (const d of readdirSync(dir, { withFileTypes: true })) {
if (!d.isDirectory() || d.name.startsWith(".")) continue
const rel = join(base === "." ? "" : base, d.name, "__init__.py")
const text = read(rel)
const v = text && /^__version__\s*=\s*["']([^"']+)["']/m.exec(text)?.[1]
if (text && v) return { file: rel, version: v, with: (n) => swap(text, /^(__version__\s*=\s*["'])([^"']+)(["'])/m, n) }
}
}
const plain = read("VERSION")?.trim()
if (plain) return { file: "VERSION", version: plain, with: (n) => `${n}\n` }
return undefined
}
/** The next version: patch, minor, major, or one given outright. */
export function nextVersion(current: string, how: string): string {
if (/^v\d/.test(how)) how = how.slice(1)
if (SEMVER.test(how)) return how
const m = SEMVER.exec(current)
if (!m) throw new Error(`the current version "${current}" is not x.y.z; give the new one outright`)
const [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])]
// A pre-release goes to its own release: 1.2.0-beta.3 → 1.2.0.
if (m[4] && (how === "" || how === "patch")) return `${maj}.${min}.${pat}`
if (how === "major") return `${maj + 1}.0.0`
if (how === "minor") return `${maj}.${min + 1}.0`
if (how === "patch" || how === "") return `${maj}.${min}.${pat + 1}`
throw new Error(`"${how}" is not patch, minor, major or a version`)
}
export interface ChangelogRelease {
/** CHANGELOG.md with the Unreleased entries under the new version. */
text: string
/** The entries: the tag message's body. */
notes: string
}
/** Keep a Changelog: `## [Unreleased]` keeps its place, empty, and its entries move under
* `## [X.Y.Z] — date`. undefined when there is nothing under Unreleased. */
export function releaseChangelog(text: string, version: string, date: string): ChangelogRelease | undefined {
const head = /^## \[?Unreleased\]?[^\n]*\n/im.exec(text)
if (!head) return undefined
const start = head.index + head[0].length
const next = /^## /m.exec(text.slice(start))
const end = next ? start + next.index : text.length
const notes = text.slice(start, end).trim()
if (!notes) return undefined
const out = `${text.slice(0, start)}\n## [${version}] — ${date}\n\n${notes}\n\n${text.slice(end)}`
// Link references at the bottom, if the file keeps them: Unreleased compares from the new tag.
const withLinks = out.replace(new RegExp(`^\\[Unreleased\\]:\\s*(\\S+?)\\/compare\\/(v?)(${VERSIONISH})\\.\\.\\.HEAD\\s*$`, "im"), (_m, base: string, v: string, prev: string) => `[Unreleased]: ${base}/compare/${v}${version}...HEAD\n[${version}]: ${base}/compare/${v}${prev}...${v}${version}`)
return { text: withLinks.replace(/\n{3,}/g, "\n\n"), notes }
}
export interface ReleasePlan {
/** Only the tag is missing: HEAD is already "Release <version>" (a tag that failed last time). */
tagOnly?: boolean
/** The tag message's body when there is no changelog move to take it from. */
notes?: string
root: string
name: string
from: string
to: string
tag: string
versionFile: VersionFile
changelog?: ChangelogRelease
sign: boolean
/** Why it cannot go ahead, if it cannot. */
blocked?: string
}
const today = () => new Date().toISOString().slice(0, 10)
/** v-prefixed tags unless the repository's tags are not. */
const tagPrefix = (root: string) => (git(root, ["tag", "--list", "v[0-9]*"]).out || !git(root, ["tag", "--list"]).out ? "v" : "")
const signs = (root: string) => git(root, ["config", "--bool", "tag.gpgsign"]).out === "true" || git(root, ["config", "user.signingkey"]).out !== ""
function projectName(root: string): string {
try {
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name?.replace(/^@[^/]+\//, "") || basename(root)
} catch {
return basename(root)
}
}
export function planRelease(root: string, how: string): ReleasePlan | { blocked: string } {
const vf = findVersion(root)
if (!vf) return { blocked: "no version found (package.json, pyproject.toml, Cargo.toml, __init__.py __version__ or VERSION)" }
const prefix = tagPrefix(root)
// The last /release committed but could not tag (a locked key): finish that one, do not skip it.
if (!how.trim() && git(root, ["log", "-1", "--format=%s"]).out === `Release ${vf.version}` && git(root, ["rev-parse", "-q", "--verify", `refs/tags/${prefix}${vf.version}`]).code !== 0) {
const cl = existsSync(join(root, "CHANGELOG.md")) ? readFileSync(join(root, "CHANGELOG.md"), "utf8") : ""
const sec = new RegExp(`^## \\[?${vf.version.replace(/\./g, "\\.")}\\]?[^\\n]*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, "m").exec(cl)?.[1]?.trim()
return { root, name: projectName(root), from: vf.version, to: vf.version, tag: `${prefix}${vf.version}`, versionFile: vf, sign: signs(root), tagOnly: true, notes: sec }
}
let to: string
try {
to = nextVersion(vf.version, how.trim())
} catch (e) {
return { blocked: (e as Error).message }
}
const tag = `${prefix}${to}`
if (git(root, ["rev-parse", "-q", "--verify", `refs/tags/${tag}`]).code === 0) return { blocked: `the tag ${tag} already exists` }
const dirty = git(root, ["status", "--porcelain", "--untracked-files=no"]).out
if (dirty) return { blocked: `the working tree has uncommitted changes — commit them first:\n${dirty}` }
const cl = existsSync(join(root, "CHANGELOG.md")) ? releaseChangelog(readFileSync(join(root, "CHANGELOG.md"), "utf8"), to, today()) : undefined
return { root, name: projectName(root), from: vf.version, to, tag, versionFile: vf, changelog: cl, sign: signs(root) }
}
/** Do it: write, commit by name, tag. Returns what happened, line by line. */
export function doRelease(p: ReleasePlan, push = false): { ok: boolean; lines: string[] } {
const lines: string[] = []
// The plan may be minutes old: the tree and the version must still be what it saw.
const dirty = git(p.root, ["status", "--porcelain", "--untracked-files=no"]).out
if (dirty) return { ok: false, lines: [`the working tree changed since the plan — nothing done:\n${dirty}`] }
if (findVersion(p.root)?.version !== p.from) return { ok: false, lines: ["the version changed since the plan — nothing done; run /release again"] }
const files = [p.versionFile.file]
if (p.from !== p.to) {
writeFileSync(join(p.root, p.versionFile.file), p.versionFile.with(p.to))
for (const a of p.versionFile.also ?? []) {
writeFileSync(join(p.root, a.file), a.with(p.to))
files.push(a.file)
}
}
if (p.changelog) {
writeFileSync(join(p.root, "CHANGELOG.md"), p.changelog.text)
files.push("CHANGELOG.md")
}
const add = git(p.root, ["add", "--", ...files])
if (add.code) return { ok: false, lines: [`git add: ${add.err}`] }
if (git(p.root, ["diff", "--cached", "--quiet"]).code !== 0) {
const c = git(p.root, ["commit", "-q", "-m", `Release ${p.to}`])
if (c.code) return { ok: false, lines: [`commit failed: ${c.err || c.out}`] }
lines.push(`committed ${git(p.root, ["log", "-1", "--format=%h %s"]).out}`)
}
const message = `${p.name} ${p.to}\n\n${p.changelog?.notes ?? p.notes ?? ""}`.trim() + "\n"
// verbatim: by default git strips every line starting with "#" — the "### Added" headings.
const t = git(p.root, ["tag", p.sign ? "-s" : "-a", "--cleanup=verbatim", p.tag, "-F", "-"], { input: message })
if (t.code) return { ok: false, lines: [...lines, `tag failed: ${t.err || t.out}`] }
lines.push(`tagged ${p.tag}${p.sign ? " (signed)" : ""}`)
if (push) {
// The tag goes where the branch goes.
const remote = git(p.root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).out.split("/")[0] || "origin"
const branch = git(p.root, ["push", "-q"])
const tag = branch.code ? branch : git(p.root, ["push", "-q", remote, p.tag])
if (tag.code) return { ok: false, lines: [...lines, `push failed: ${tag.err || tag.out}`] }
lines.push(`pushed the branch and ${p.tag}`)
} else lines.push(`nothing pushed — when ready: git push && git push ${git(p.root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).out.split("/")[0] || "origin"} ${p.tag}`)
return { ok: true, lines }
}
+50
View File
@@ -0,0 +1,50 @@
import { git } from "./run.ts"
export interface RepoState {
branch: string
upstream?: string
ahead: number
behind: number
/** Changed paths, as `git status --porcelain` codes: " M src/a.ts", "?? new.txt". */
changes: string[]
recent: string[]
detached: boolean
}
/** Branch, divergence, working-tree changes and the last commits — or undefined outside a repo. */
export function repoState(root: string, commits = 5): RepoState | undefined {
const st = git(root, ["status", "--porcelain=v1", "--branch", "--untracked-files=normal"])
if (st.code !== 0) return undefined
const [head = "", ...changes] = st.out.split("\n")
// "## main...origin/main [ahead 2, behind 1]" | "## HEAD (no branch)" | "## No commits yet on main"
const m = /^## (?:No commits yet on )?(.+?)(?:\.\.\.(\S+))?(?: \[(.+)\])?$/.exec(head)
const detached = head.includes("(no branch)")
const info = m?.[3] ?? ""
const log = git(root, ["log", `-${commits}`, "--format=%h %s"])
return {
branch: detached ? "HEAD (detached)" : (m?.[1] ?? "?"),
upstream: m?.[2],
ahead: Number(/ahead (\d+)/.exec(info)?.[1] ?? 0),
behind: Number(/behind (\d+)/.exec(info)?.[1] ?? 0),
changes: changes.filter(Boolean),
recent: log.code === 0 && log.out ? log.out.split("\n") : [],
detached,
}
}
/** The git block of the system prompt. */
export function describeRepo(s: RepoState): string {
const lines = [`Branch: ${s.branch}${s.upstream ? ` (tracking ${s.upstream}${s.ahead || s.behind ? `, ahead ${s.ahead}, behind ${s.behind}` : ", up to date"})` : " (no upstream)"}`]
if (s.changes.length) {
const shown = s.changes.slice(0, 20).map((c) => ` ${c}`)
lines.push(`Uncommitted changes (${s.changes.length}):`, ...shown, ...(s.changes.length > 20 ? [` … ${s.changes.length - 20} more`] : []))
} else lines.push("Working tree clean.")
if (s.recent.length) lines.push("Recent commits:", ...s.recent.map((c) => ` ${c}`))
else lines.push("No commits yet.")
return lines.join("\n")
}
export function gitInit(root: string): { ok: boolean; message: string } {
const r = git(root, ["init", "-q", "-b", "main"])
return r.code === 0 ? { ok: true, message: `initialised an empty git repository in ${root} (branch main, no remote)` } : { ok: false, message: r.err }
}
+23
View File
@@ -0,0 +1,23 @@
// Plain git, synchronously. Every call is scoped with -C; output is trimmed text.
export interface GitResult {
code: number
out: string
err: string
}
export function git(cwd: string, args: string[], opts: { env?: Record<string, string>; input?: string; timeoutMs?: number } = {}): GitResult {
// core.fsmonitor names a program git runs on status — off for every call made here, before and
// after a project is trusted.
const r = Bun.spawnSync(["git", "-c", "core.quotepath=false", "-c", "core.fsmonitor=false", "-C", cwd, ...args], {
// Nothing may hang the app: a signing key that wants a passphrase, a slow hook.
timeout: opts.timeoutMs ?? 120_000,
stdout: "pipe",
stderr: "pipe",
stdin: opts.input !== undefined ? new TextEncoder().encode(opts.input) : "ignore",
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", LC_ALL: "C", ...opts.env },
})
const err = r.stderr.toString().trim()
return { code: r.exitCode ?? 1, out: r.stdout.toString().replace(/\s+$/, ""), err: r.exitCode === null && !err ? `git ${args[0]} did not finish in ${Math.round((opts.timeoutMs ?? 120_000) / 1000)} s (a key waiting for a passphrase? a slow hook?)` : err }
}
export const hasGit = Bun.which("git") !== null
+193
View File
@@ -0,0 +1,193 @@
// Snapshots of the working tree in a shadow git repository — the idea of OpenCode's
// snapshot/index.ts, written small. The shadow repo lives in ~/.local/share/lembas/snapshot/,
// never inside the project, and works whether or not the project itself uses git.
//
// track() → a tree hash for the working tree as it is now
// changed(a, b) → paths that differ between two trees
// restore(tree, ps) → put those paths back as they were in `tree` (deleting ones it lacks)
// checkpoint(label) → a named snapshot that outlives the session (/checkpoint)
//
// Restoring by path, never the whole tree, is what makes /undo safe: files the turn did not
// touch are left alone, whatever happened to them since.
import { createHash } from "node:crypto"
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
import { LOCAL_DIRS } from "../project/root.ts"
import { git } from "./run.ts"
const MAX_FILES = 20_000
const MAX_FILE_BYTES = 2 * 1024 * 1024
/** A named snapshot: a commit in the shadow store under refs/checkpoints/, so it is kept. */
export interface Checkpoint {
ref: string
label: string
at: number
tree: string
}
export class Snapshots {
readonly gitdir: string
enabled = true
reason = ""
private ready = false
private bigExcluded = new Set<string>()
constructor(
readonly root: string,
readonly realGitRoot?: string,
private maxFiles = MAX_FILES,
) {
const id = createHash("sha1").update(root).digest("hex").slice(0, 16)
this.gitdir = join(paths.data, "snapshot", id)
}
private g(args: string[], input?: string) {
return git(this.root, ["--git-dir", this.gitdir, "--work-tree", this.root, "-c", "core.autocrlf=false", ...args], { input })
}
private init(): boolean {
if (this.ready) return this.enabled
this.ready = true
if (!existsSync(join(this.gitdir, "HEAD"))) {
mkdirSync(this.gitdir, { recursive: true })
const r = git(this.root, ["init", "-q", "--bare", this.gitdir])
if (r.code !== 0) return this.disable(`cannot create the snapshot store: ${r.err}`)
for (const [k, v] of [["core.autocrlf", "false"], ["core.fsmonitor", "false"], ["gc.auto", "0"], ["core.bare", "false"]] as const)
git(this.root, ["--git-dir", this.gitdir, "config", k, v])
}
// Borrow the project's own object store: files git already hashed are not hashed again.
if (this.realGitRoot) {
const common = git(this.realGitRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"])
if (common.code === 0 && existsSync(join(common.out, "objects"))) {
mkdirSync(join(this.gitdir, "objects", "info"), { recursive: true })
writeFileSync(join(this.gitdir, "objects", "info", "alternates"), join(common.out, "objects") + "\n")
}
}
this.writeExcludes()
const count = this.g(["ls-files", "--others", "--cached", "--exclude-standard", "-z"])
const n = count.out ? count.out.split("\0").filter(Boolean).length : 0
if (n > this.maxFiles) return this.disable(`${n} files — too many to snapshot (limit ${this.maxFiles}); /undo is off here`)
return true
}
private disable(reason: string): false {
this.enabled = false
this.reason = reason
return false
}
private writeExcludes() {
const lines = ["/.git", ...LOCAL_DIRS.map((d) => `/${d}`), "node_modules/", ...[...this.bigExcluded].map((p) => `/${p}`)]
if (this.realGitRoot) {
const own = join(this.realGitRoot, ".git", "info", "exclude")
if (existsSync(own)) lines.push(readFileSync(own, "utf8"))
}
mkdirSync(join(this.gitdir, "info"), { recursive: true })
writeFileSync(join(this.gitdir, "info", "exclude"), lines.join("\n") + "\n")
}
/** Keep new large files (logs, builds, media) out: a snapshot store is not a backup. */
private excludeBig() {
const others = this.g(["ls-files", "--others", "--exclude-standard", "-z"])
let added = false
for (const p of others.out.split("\0").filter(Boolean)) {
try {
if (statSync(join(this.root, p)).size > MAX_FILE_BYTES && !this.bigExcluded.has(p)) {
this.bigExcluded.add(p)
added = true
}
} catch {}
}
if (added) this.writeExcludes()
}
/** The working tree now, as a tree hash; undefined when snapshots are off. */
track(): string | undefined {
if (!this.init()) return undefined
this.excludeBig()
const add = this.g(["add", "--all", "--", "."])
if (add.code !== 0) {
this.disable(`snapshot failed: ${add.err.split("\n")[0]}`)
return undefined
}
const tree = this.g(["write-tree"])
return tree.code === 0 ? tree.out : undefined
}
changed(from: string, to: string): string[] {
const r = this.g(["diff", "--name-only", "--no-renames", "-z", from, to, "--", "."])
return r.code === 0 ? r.out.split("\0").filter(Boolean) : []
}
/** Unified diff between two trees (or from a tree to the working tree when `to` is omitted). */
diff(from: string, to?: string): string {
const r = this.g(["diff", "--no-ext-diff", "--no-renames", "--no-color", from, ...(to ? [to] : []), "--", "."])
return r.code === 0 ? r.out : ""
}
/** Save the working tree as it is now under a name. Its objects are copied into the shadow
* store: the project's own store (borrowed for speed) may drop them in a gc, and a checkpoint
* can be weeks old by the time it is wanted. */
checkpoint(label: string): Checkpoint | undefined {
const tree = this.track()
if (!tree) return undefined
const at = Date.now()
const c = this.g(["-c", "user.name=lembas", "-c", "user.email=lembas@localhost", "commit-tree", tree, "-m", label])
if (c.code !== 0) return undefined
const ref = `refs/checkpoints/${at.toString(36)}${Math.random().toString(36).slice(2, 5)}`
if (this.g(["update-ref", ref, c.out]).code !== 0) return undefined
this.g(["pack-objects", "--revs", "--quiet", join(this.gitdir, "objects", "pack", "pack")], `${c.out}\n`)
return { ref, label, at, tree }
}
/** Checkpoints of this project, newest first. */
checkpoints(): Checkpoint[] {
if (!this.init()) return []
const r = this.g(["for-each-ref", "--sort=-creatordate", "--format=%(refname)%00%(tree)%00%(creatordate:unix)%00%(contents:subject)", "refs/checkpoints/"])
if (r.code !== 0 || !r.out) return []
// The ref's name carries the time to the millisecond (git's dates stop at the second).
return r.out
.split("\n")
.map((l) => {
const [ref = "", tree = "", at = "0", label = ""] = l.split("\0")
const ms = Number.parseInt(ref.slice("refs/checkpoints/".length, -3), 36)
return { ref, tree, at: Number.isFinite(ms) ? ms : Number(at) * 1000, label }
})
.sort((a, b) => b.at - a.at)
}
dropCheckpoint(ref: string): boolean {
return ref.startsWith("refs/checkpoints/") && this.g(["update-ref", "-d", ref]).code === 0
}
/** Put the working tree back as it was at a checkpoint — only the files that differ. What was
* there is saved first as a checkpoint of its own, so a restore can itself be undone. */
restoreCheckpoint(cp: Checkpoint): { files: string[]; saved?: Checkpoint } | undefined {
const saved = this.checkpoint(`before going back to "${cp.label}"`)
if (!saved) return undefined
const files = this.changed(cp.tree, saved.tree)
if (!files.length) {
this.dropCheckpoint(saved.ref)
return { files }
}
return { files: this.restore(cp.tree, files), saved }
}
/** Put `files` back as they are in `tree`; a file the tree does not have is deleted. */
restore(tree: string, files: string[]): string[] {
const done: string[] = []
for (const f of files) {
const has = this.g(["ls-tree", "--name-only", tree, "--", f]).out.trim() !== ""
if (has) {
const r = this.g(["checkout", tree, "--", f])
if (r.code === 0) done.push(f)
} else {
rmSync(join(this.root, f), { force: true })
done.push(f)
}
}
return done
}
}
+57
View File
@@ -0,0 +1,57 @@
// Worktrees for subagents: a subagent that changes files can work in a checkout of its own, on a
// branch of its own, so two of them (or one and you) never edit the same files at once. When it
// is done its changes are committed on that branch and the checkout goes; the branch stays for
// you to merge or delete. A worktree starts from HEAD: uncommitted work is not in it.
import { mkdirSync } from "node:fs"
import { join } from "node:path"
import { createHash } from "node:crypto"
import { paths } from "../config/paths.ts"
import { git } from "./run.ts"
export interface Worktree {
dir: string
branch: string
base: string
}
export function createWorktree(repo: string, label: string): Worktree {
const id = Math.random().toString(36).slice(2, 8)
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30) || "agent"
const branch = `lembas/${slug}-${id}`
const dir = join(paths.data, "worktrees", createHash("sha256").update(repo).digest("hex").slice(0, 12), `${slug}-${id}`)
mkdirSync(join(dir, ".."), { recursive: true })
// Records of checkouts whose directories are gone (a crash, a manual rm) go first.
git(repo, ["worktree", "prune"])
const base = git(repo, ["rev-parse", "HEAD"]).out
if (!base) throw new Error("the repository has no commit yet, so there is nothing to branch from")
const r = git(repo, ["worktree", "add", "-q", "-b", branch, dir, base])
if (r.code) throw new Error(`git worktree add: ${r.err}`)
return { dir, branch, base }
}
export interface WorktreeResult {
/** What to tell the model and the user. */
summary: string
kept: boolean
}
/** Commit what the subagent left, remove the checkout, and keep the branch only if it has work. */
export function finishWorktree(repo: string, wt: Worktree, message: string): WorktreeResult {
// The checkout is the subagent's own, so everything in it is its work.
git(wt.dir, ["add", "-A"])
if (git(wt.dir, ["diff", "--cached", "--quiet"]).code !== 0) {
const c = git(wt.dir, ["commit", "-q", "-m", message])
if (c.code) return { summary: `Its changes could not be committed (${c.err || c.out}); they are still in ${wt.dir} on branch ${wt.branch}.`, kept: true }
}
const commits = Number(git(repo, ["rev-list", "--count", `${wt.base}..${wt.branch}`]).out || "0")
git(repo, ["worktree", "remove", "--force", wt.dir])
if (!commits) {
git(repo, ["branch", "-D", wt.branch])
return { summary: "It changed nothing, so its branch was removed.", kept: false }
}
const stat = git(repo, ["diff", "--shortstat", wt.base, wt.branch]).out.trim()
return {
summary: `Its work is on branch ${wt.branch} (${commits} commit${commits > 1 ? "s" : ""}: ${stat}), not in your working tree. Look with \`git diff HEAD...${wt.branch}\`; take it with \`git merge ${wt.branch}\`; drop it with \`git branch -D ${wt.branch}\`.`,
kept: true,
}
}
+84
View File
@@ -0,0 +1,84 @@
// The harness spec (harness/, shared with LLeMbas): its version and its tools, embedded at build
// time. The spec is the source of every tool's name, description and parameters as the model sees
// them; the code validates with its own zod schema, and tests/harness.test.ts keeps the two equal.
import VERSION from "../harness/VERSION" with { type: "text" }
import PURPOSE from "../harness/purpose.json"
import t_apply_patch from "../harness/tools/apply_patch.json"
import t_ask_user from "../harness/tools/ask_user.json"
import t_bash from "../harness/tools/bash.json"
import t_bash_kill from "../harness/tools/bash_kill.json"
import t_bash_list from "../harness/tools/bash_list.json"
import t_bash_output from "../harness/tools/bash_output.json"
import t_decisions from "../harness/tools/decisions.json"
import t_edit from "../harness/tools/edit.json"
import t_glob from "../harness/tools/glob.json"
import t_grep from "../harness/tools/grep.json"
import t_knowledge_get from "../harness/tools/knowledge_get.json"
import t_knowledge_search from "../harness/tools/knowledge_search.json"
import t_list from "../harness/tools/list.json"
import t_memory from "../harness/tools/memory.json"
import t_multiedit from "../harness/tools/multiedit.json"
import t_note_manage from "../harness/tools/note_manage.json"
import t_note_view from "../harness/tools/note_view.json"
import t_notes_search from "../harness/tools/notes_search.json"
import t_plan_submit from "../harness/tools/plan_submit.json"
import t_read from "../harness/tools/read.json"
import t_session_search from "../harness/tools/session_search.json"
import t_settings from "../harness/tools/settings.json"
import t_skill_manage from "../harness/tools/skill_manage.json"
import t_skill_view from "../harness/tools/skill_view.json"
import t_skills_list from "../harness/tools/skills_list.json"
import t_task from "../harness/tools/task.json"
import t_tasks from "../harness/tools/tasks.json"
import t_todo from "../harness/tools/todo.json"
import t_view_image from "../harness/tools/view_image.json"
import t_web_fetch from "../harness/tools/web_fetch.json"
import t_web_search from "../harness/tools/web_search.json"
import t_write from "../harness/tools/write.json"
export interface ToolSpecDef {
name: string
/** shared: both projects implement it; cli / llembas: only that one does. The execution tools
* (bash, edit, read…) are the CLI's: the web UI runs no agent of its own. */
scope: "shared" | "cli" | "llembas"
risk: "read" | "write" | "execute" | "interact"
exclusive: boolean
/** How a call is shown in a transcript: a shell command, an edit with its diff, a
* file read, a search with its hits, a web lookup, a delegated task, hidden (the todo list has a
* panel of its own), or a plain tool line. Both projects draw the same kind the same way. */
block: "shell" | "edit" | "read" | "search" | "web" | "task" | "hidden" | "tool"
/** Where a call says what it is for: the injected `purpose`, a field of its own, or none. */
purpose: string | false
/** Names each project used before spec v1; a call under one is read as this tool. */
aliases: { cli: string[]; llembas: string[] }
argument_aliases?: Record<string, string>
/** `{{name}}` in the description: a fact the projects differ in, filled in by each (fillDescription). */
variables?: Record<string, string>
description: string
parameters: Record<string, unknown>
}
export const HARNESS_VERSION = VERSION.trim()
/** The `purpose` argument (harness/purpose.json). */
export const PURPOSE_DEF: { name: string; schema: Record<string, unknown> } = PURPOSE
/** A description with its `{{variables}}` filled in. A variable left unfilled is an error: it
* would reach the model as a literal `{{name}}`. */
export function fillDescription(description: string, vars: Record<string, string>): string {
return description.replace(/\{\{(\w+)\}\}/g, (m, k: string) => {
if (!Object.hasOwn(vars, k)) throw new Error(`harness: no value for {{${k}}} in a tool description`)
return vars[k]!
})
}
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
([t_apply_patch, t_ask_user, t_bash, t_bash_kill, t_bash_list, t_bash_output, t_decisions, t_edit, t_glob, t_grep, 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_read, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_todo, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
)
/** A tool's name and description as the spec has them, for its definition: `...described("read")`. */
export function described(name: string): { name: string; description: string } {
const t = TOOL_SPECS[name]
if (!t) throw new Error(`harness: no tool ${name} in the spec`)
return { name: t.name, description: t.description }
}
+169
View File
@@ -0,0 +1,169 @@
// `lembas run "…"`: one prompt, no TUI. Text goes to stdout, tool lines and notices to stderr
// (so the answer can be piped), or everything as JSON lines with --json.
import { createInterface } from "node:readline/promises"
import type { Asker, AskReply, Bus, Event } from "./bus/index.ts"
import { unattendedReply, type Answer, type QuestionReply, type QuestionRequest } from "./tool/question.ts"
import type { PlanReply } from "./tool/plan_exit.ts"
import type { IconSet } from "./tui/icons.ts"
import { loadTheme, type Theme } from "./tui/palette.ts"
import { duration } from "./duration.ts"
/** The theme's colours as 24-bit escapes — the same theme the TUI uses (`theme` in config.yaml). */
function escapes(theme: Theme) {
const fg = (hex: string) => {
const n = Number.parseInt(hex.slice(1), 16)
return `\x1b[38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}m`
}
const k = theme.colors
return { label: fg(k.label), dim: fg(k.dim), ok: fg(k.ok), err: fg(k.error), warn: fg(k.warn), think: "\x1b[2;3m", reset: "\x1b[0m" }
}
export function renderPlain(bus: Bus, opts: { color: boolean; showReasoning: boolean; icons?: IconSet; theme?: string }) {
const theme = loadTheme(opts.theme, opts.icons).theme
const C = escapes(theme)
const icon = (name: string) => theme.toolEmoji[name] ?? (name.startsWith("mcp__") ? theme.icons.mcp : theme.icons.other)
const c = (code: string, s: string) => (opts.color ? code + s + C.reset : s)
let inReasoning = false
let atLineStart = true
let started = false
const out = (s: string) => {
if (!s) return
process.stdout.write(s)
atLineStart = s.endsWith("\n")
}
const line = (s: string) => {
if (!atLineStart) out("\n")
process.stderr.write(s + "\n")
}
bus.on((e: Event) => {
if (e.type !== "reasoning" && inReasoning) {
inReasoning = false
if (opts.showReasoning) out("\n")
}
switch (e.type) {
case "text": {
// Models often open with blank lines (after their reasoning); they are not the answer.
const t = started ? e.text : e.text.trimStart()
if (t) started = true
out(t)
break
}
case "reasoning":
if (!opts.showReasoning) break
inReasoning = true
out(c(C.think, e.text))
break
case "tool_end": {
const verb = e.name.padEnd(6)
const mark = e.result.isError ? c(C.err, "✗") : ""
const title = e.result.title ?? (e.result.isError ? e.result.output.split("\n")[0]!.slice(0, 120) : "")
line(`${c(C.dim, "┊")} ${icon(e.name)} ${c(C.label, verb)} ${title} ${c(C.dim, duration(e.ms))} ${mark}`)
break
}
case "sub_tool":
line(` ${c(C.dim, "┊")} ${icon(e.name)} ${c(C.dim, e.name.padEnd(6))} ${e.title}`)
break
case "tool_denied":
line(`${c(C.dim, "┊")} ${theme.icons.denied} ${c(C.warn, e.name.padEnd(6))} ${e.reason.split("\n")[0]!.slice(0, 140)}`)
break
case "retract":
if (!atLineStart) out("\n")
started = false
break
case "notice":
line(c(C.warn, `⚠ ${e.message}`))
break
case "error":
line(c(C.err, `✗ ${e.message}`))
break
case "done":
if (!atLineStart) out("\n")
if (e.reason === "steps") line(c(C.warn, "⚠ stopped at the step limit"))
break
}
})
}
export function renderJson(bus: Bus) {
bus.on((e) => process.stdout.write(JSON.stringify(e) + "\n"))
}
/** Asks on the terminal when there is one; otherwise follows --yes, or refuses. */
export function terminalAsker(opts: { yes: boolean; unattended?: "recommended" | "first" | "fail"; theme?: string }): Asker {
const C = escapes(loadTheme(opts.theme).theme)
return {
// With nobody at a terminal there is no plan method at all: plan_exit then saves and stops.
...(process.stdin.isTTY && !opts.yes
? {
async plan(req: { path: string; text: string }): Promise<PlanReply> {
const rl = createInterface({ input: process.stdin, output: process.stderr })
try {
process.stderr.write(`\n${C.label}── plan: ${req.path} ──${C.reset}\n${req.text}\n${C.label}──${C.reset}\n`)
const a = (await rl.question(" [a]pprove (edit mode) [m]anual mode [r]evise… [?] ask back… [enter] keep planning › ")).trim().toLowerCase()
if (a === "a") return { kind: "approve", mode: "edit" }
if (a === "m") return { kind: "approve", mode: "manual" }
if (a === "r") return { kind: "revise", feedback: (await rl.question(" what should change? ")).trim() }
if (a === "?") return { kind: "back", text: (await rl.question(" your question: ")).trim() }
return { kind: "dismissed" }
} finally {
rl.close()
}
},
}
: {}),
async question(req: QuestionRequest): Promise<QuestionReply> {
if (opts.yes || !process.stdin.isTTY) return unattendedReply(req, opts.unattended ?? "recommended")
const rl = createInterface({ input: process.stdin, output: process.stderr })
try {
if (req.why) process.stderr.write(`\n${C.dim}${req.why}${C.reset}\n`)
const answers: Answer[] = []
for (const [i, q] of req.questions.entries()) {
process.stderr.write(`\n${C.warn}? [${q.header}]${C.reset} ${q.question}${req.questions.length > 1 ? ` (${i + 1}/${req.questions.length})` : ""}\n`)
q.options.forEach((o, n) =>
process.stderr.write(` ${n + 1}. ${o.label}${o.recommended ? " (Recommended)" : ""}${o.description ? `${C.dim} — ${o.description}${C.reset}` : ""}\n`),
)
const hint = `number${q.multiple ? "s (1,3)" : ""}, your own answer, ?question to ask back${q.options.some((o) => o.recommended) ? ", enter = recommended" : ""}, - to skip all`
const raw = (await rl.question(` ${C.dim}${hint}${C.reset} › `)).trim()
if (raw === "-") return { dismissed: true }
if (raw.startsWith("?")) answers.push({ kind: "back", text: raw.slice(1).trim() })
else if (!raw) {
const rec = q.options.find((o) => o.recommended) ?? q.options[0]
answers.push(rec ? { kind: "options", labels: [rec.label] } : { kind: "custom", text: "" })
} else if (/^\d+(\s*,\s*\d+)*$/.test(raw)) {
const picked = raw.split(",").map((n) => q.options[Number(n) - 1]?.label).filter((l): l is string => !!l)
answers.push(picked.length ? { kind: "options", labels: q.multiple ? picked : picked.slice(0, 1) } : { kind: "custom", text: raw })
} else answers.push({ kind: "custom", text: raw })
}
return { answers }
} finally {
rl.close()
}
},
async ask({ tool, args, request, decision, preview, purpose }): Promise<AskReply> {
if (opts.yes) return { kind: "once" }
if (!process.stdin.isTTY)
return { kind: "deny", final: true, feedback: `nobody is present to approve ${tool} in this headless run.` }
const what = request.command ?? request.patterns.join(", ")
const rl = createInterface({ input: process.stdin, output: process.stderr })
try {
process.stderr.write(`\n${C.warn}? ${tool}${C.reset} ${what}\n`)
if (purpose) process.stderr.write(` ${C.label}${purpose}${C.reset}\n`)
if (preview?.diff)
for (const l of preview.diff.split("\n").filter((l) => !/^(Index:|={5,}|--- |\+\+\+ )/.test(l)))
process.stderr.write(` ${l.startsWith("+") ? C.ok : l.startsWith("-") ? C.err : l.startsWith("@@") ? C.dim : ""}${l}${C.reset}\n`)
if (preview?.error) process.stderr.write(` ${C.err}this will fail: ${preview.error}${C.reset}\n`)
process.stderr.write(` ${C.dim}${decision.reason}${C.reset}\n`)
void args
const always = decision.always.length ? ` (${decision.always.join(", ")})` : ""
const answer = (await rl.question(` [y]es [s]ession-always${always} [p]roject-always [n]o [r]eason… `)).trim().toLowerCase()
if (answer === "y" || answer === "yes") return { kind: "once" }
if (answer === "s") return { kind: "session" }
if (answer === "p") return { kind: "project" }
if (answer === "r") return { kind: "deny", feedback: (await rl.question(" why? ")).trim() }
return { kind: "deny" }
} finally {
rl.close()
}
},
}
}
+349
View File
@@ -0,0 +1,349 @@
// Talking to a LLeMbas instance: finding it, signing this machine in by device code (RFC 8628),
// reading the account's models and what else it offers, and signing out. The `webui` connection
// (webui.ts) uses the same calls at every start.
//
// The instance's address is all a person types. `/.well-known/lembas.json` says whether it is
// LLeMbas at all, which versions it speaks, and where to log in, so nobody is asked to approve
// anything for a server that only looks like one.
import { existsSync, readFileSync } from "node:fs"
import { hostname } from "node:os"
import { VERSION } from "../version.ts"
/** The device protocol this build speaks (LLeMbas `api/devices.py:PROTOCOL`): 2, the
* same number as the link's `_lembas/*` extension (acp/agent.ts LEMBAS_PROTOCOL). */
export const PROTOCOL = 2
/** The ones it can log in to: protocol 2 only adds what both sides name in their capabilities, so an
* instance still at 1 works with what it has. */
export const PROTOCOLS = [1, 2]
export class LembasError extends Error {}
export interface Discovery {
service: "lembas"
version: string
/** The instance's own name (Example), where it gives one. */
name?: string
/** What a device calls it: its name as a connection name (example). */
connection?: string
protocol: number
/** Every link protocol it speaks (`protocol: 1, protocols: [1, 2]` — `protocol` stays the
* lowest, so a client that compares it for equality still logs in). */
protocols?: number[]
harness_spec: string
base_url: string
api: { openai: string; instance?: string }
login: { device: { code: string; token: string; verify: string } }
}
/** One entry of /v1/models: OpenAI's fields, then the harness spec's model metadata. */
export interface ServedModel {
id: string
name?: string
family?: string
context?: number
max_output?: number
temperature?: number
top_p?: number
efforts?: string[]
effort?: string
vision?: boolean
tools?: boolean
notes?: string
capacity?: { group?: string; single_session?: boolean }
default?: boolean
/** The provider's slug — the part of `id` before its `/` — and what kind of model it is. */
provider?: string
/** "chat", "embedding", "stt", "tts" or "image"; absent from an older instance, which
* lists chat models only. */
kind?: string
}
export interface Tls {
ca?: string
}
/** Where a CA bundle that includes the system's own (and so an estate's internal CA, once it is
* installed system-wide) usually is. Bun trusts its built-in roots, not these, so a private CA
* needs naming — and this is the name to try before asking. */
export const SYSTEM_BUNDLES = ["/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem"]
function tlsInit(tls: Tls | undefined): { tls?: { ca: string } } {
if (!tls?.ca) return {}
try {
return { tls: { ca: readFileSync(tls.ca, "utf8") } }
} catch {
throw new LembasError(`cannot read the CA file ${tls.ca}`)
}
}
function untrusted(e: unknown): boolean {
const code = String((e as { code?: string }).code ?? "")
return /CERT|SIGNATURE|SELF_SIGNED|UNABLE_TO_GET_ISSUER/.test(code)
}
async function call(url: string, init: RequestInit, tls: Tls | undefined, timeoutMs = 15_000): Promise<Response> {
try {
return await fetch(url, { ...init, ...tlsInit(tls), signal: AbortSignal.timeout(timeoutMs) } as RequestInit)
} catch (e) {
if (untrusted(e)) throw new LembasError(`the TLS certificate of ${new URL(url).host} is not trusted. Pass --ca <file> with the CA that signed it.`)
if ((e as Error).name === "TimeoutError") throw new LembasError(`${new URL(url).host} did not answer in time`)
throw new LembasError(`cannot reach ${new URL(url).host}: ${(e as Error).message}`)
}
}
/** What a person typed, as a base address: a scheme added (https unless they said http), the path
* and a trailing slash dropped. */
export function normalise(address: string): string {
let a = address.trim()
if (!a) throw new LembasError("an instance address is needed, e.g. ai.example.org")
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(a)) a = `https://${a}`
let u: URL
try {
u = new URL(a)
} catch {
throw new LembasError(`${address} is not an address`)
}
if (u.protocol !== "https:" && u.protocol !== "http:") throw new LembasError(`${address}: only http and https`)
return `${u.protocol}//${u.host}`
}
/** Find the instance. With no CA given and an untrusted certificate, the system's own bundle is
* tried once — it is what an internal CA installed on this machine is in — and returned, so the
* connection written afterwards uses it too. */
/** The protocols both this CLI and the instance speak: `protocols` where it says, else `protocol`. */
export function spoken(d: Pick<Discovery, "protocol" | "protocols">): number[] {
const theirs = Array.isArray(d.protocols) && d.protocols.length ? d.protocols : [d.protocol]
return PROTOCOLS.filter((p) => theirs.includes(p))
}
/** The `_lembas` protocol to say in the link's hello: 2 where the instance lists it, else
* 1 — an instance that speaks only 1 closes a link that says 2 (4400). Features stay gated by capabilities. */
export function linkProtocol(protocols: number[] | undefined): number {
return protocols?.includes(2) ? 2 : 1
}
export async function discover(address: string, tls?: Tls): Promise<{ discovery: Discovery; tls?: Tls }> {
const base = normalise(address)
const url = `${base}/.well-known/lembas.json`
let res: Response
let used = tls
try {
res = await call(url, { headers: { accept: "application/json" } }, tls)
} catch (e) {
const bundle = SYSTEM_BUNDLES.find((f) => existsSync(f))
if (tls?.ca || !bundle || !(e instanceof LembasError) || !/not trusted/.test(e.message)) throw e
used = { ca: bundle }
res = await call(url, { headers: { accept: "application/json" } }, used)
}
if (res.status === 404) throw new LembasError(`${base} answers, but is not a LLeMbas instance (no /.well-known/lembas.json)`)
if (!res.ok) throw new LembasError(`${base}: ${res.status} ${res.statusText}`)
let body: Discovery
try {
body = (await res.json()) as Discovery
} catch {
throw new LembasError(`${base} answered /.well-known/lembas.json with something that is not JSON`)
}
if (body?.service !== "lembas") throw new LembasError(`${base} is not a LLeMbas instance`)
if (!spoken(body).length) throw new LembasError(`${base} speaks device protocol ${body.protocol}; this LLeMbas CLI speaks ${PROTOCOLS.join(" and ")}. Update the older of the two.`)
if (!body.login?.device?.code) throw new LembasError(`${base} offers no device login`)
return { discovery: body, tls: used }
}
export interface DeviceStart {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete?: string
expires_in: number
interval: number
}
export async function startDevice(d: Discovery, tls: Tls | undefined, scopes = ["models", "library", "link"]): Promise<DeviceStart> {
const res = await call(
d.login.device.code,
{
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
client_id: `lembas-cli ${VERSION}`,
// The machine's name alone: the program is said by client_id, and the web UI shows this
// where room is short.
device_name: hostname(),
device_host: hostname(),
device_platform: `${process.platform}-${process.arch}`,
scope: scopes.join(" "),
}),
},
tls,
)
const body = (await res.json().catch(() => ({}))) as DeviceStart & { error?: string; error_description?: string }
if (!res.ok) throw new LembasError(body.error_description ?? body.error ?? `${res.status} ${res.statusText}`)
return body
}
export interface Granted {
access_token: string
scope: string
account: { email?: string; name?: string }
base_url?: string
}
/** Poll until the person decides, the code runs out, or `signal` stops it. */
export async function awaitToken(d: Discovery, start: DeviceStart, tls: Tls | undefined, opts: { signal?: AbortSignal; sleep?: (ms: number) => Promise<void> } = {}): Promise<Granted> {
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
let interval = Math.max(1, start.interval || 5)
const deadline = Date.now() + Math.max(1, start.expires_in) * 1000
while (Date.now() < deadline) {
await sleep(interval * 1000)
if (opts.signal?.aborted) throw new LembasError("cancelled")
const res = await call(
d.login.device.token,
{
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: start.device_code }),
},
tls,
)
const body = (await res.json().catch(() => ({}))) as Granted & { error?: string }
if (res.ok && body.access_token) return body
switch (body.error) {
case "authorization_pending":
continue
case "slow_down":
interval += 5
continue
case "access_denied":
throw new LembasError("the sign-in was denied on the instance")
case "expired_token":
throw new LembasError("the code expired before it was approved — run login again")
default:
throw new LembasError(`the instance refused the sign-in: ${body.error ?? `${res.status} ${res.statusText}`}`)
}
}
throw new LembasError("the code expired before it was approved — run login again")
}
export async function models(d: { api: { openai: string } }, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<ServedModel[]> {
const res = await call(`${d.api.openai}/models`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
if (res.status === 403) throw new LembasError("this account may not use the instance's models through the API (the “Use the API” permission)")
if (!res.ok) throw new LembasError(`listing the models: ${res.status} ${res.statusText}`)
const body = (await res.json()) as { data?: ServedModel[] }
return Array.isArray(body.data) ? body.data : []
}
/** GET /api/v1/instance: the instance by name, the account's default model, and
* which of voice and web search it may use through the API. */
export interface InstanceInfo {
name?: string
connection?: string
version?: string
default_model?: string | null
services?: { stt?: boolean; tts?: boolean; search?: boolean; fetch?: boolean }
}
/** The instance's own description; undefined from an instance that does not have it (a 404). */
export async function instanceInfo(base: string, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<InstanceInfo | undefined> {
const res = await call(`${base}/api/v1/instance`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
if (res.status === 404) return undefined
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
if (!res.ok) throw new LembasError(`reading the instance: ${res.status} ${res.statusText}`)
const body = (await res.json().catch(() => undefined)) as InstanceInfo | undefined
return body && typeof body === "object" ? body : undefined
}
/** Revoke this machine's token on the instance. Best-effort: signing out locally goes ahead even
* when the instance cannot be reached, and says so. */
export async function signOut(baseUrl: string, token: string, tls: Tls | undefined): Promise<boolean> {
try {
const res = await call(`${baseUrl}/api/v1/token`, { method: "DELETE", headers: { authorization: `Bearer ${token}` } }, tls)
return res.ok || res.status === 401
} catch {
return false
}
}
/** GET /v1/usage: the account's credits this month, and this device's share. Every
* number is null on an instance that keeps no credits or plans. */
export interface Usage {
plan: { name: string; credits_per_month: number } | null
balance: number | null
/** `cost` null where the instance keeps no credits. */
month: { from: string; tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
device: { tokens_in: number; tokens_out: number; cost: number | null; cost_money?: number | null }
unit: string
// Every one optional: an instance may have none of them.
/** The money credits are counted in: "EUR", "USD" or "GBP". */
currency?: string
/** What one credit is worth, in `currency`. */
credit_value?: number | null
/** An administrator: no limit, never refused — the spend is still counted. */
admin?: boolean
/** The balance in `currency`. `month.cost_money`, `device.cost_money` likewise. */
balance_money?: number | null
/** Rolling spending windows (e.g. five hours, a week): how much of each is used, and when the
* oldest of it frees again. */
windows?: { window: string | number; limit: number | null; spent: number; frees_at?: string | null }[]
}
/** The usage, or undefined from an instance that does not have the endpoint. */
export async function usage(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Usage | undefined> {
const res = await call(`${base}/v1/usage`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
if (res.status === 404) return undefined
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
if (!res.ok) throw new LembasError(`reading the usage: ${res.status} ${res.statusText}`)
const body = (await res.json().catch(() => undefined)) as Usage | undefined
return body && typeof body === "object" ? body : undefined
}
/** GET/PUT /v1/me/personalization: how the account wants to be helped, the same
* three things a person says about themselves here (personality, personality_custom, instructions).
* `available`: the administrator allows it; `enabled`: and the person has it on. */
export interface Personalization {
enabled: boolean
available: boolean
personality: string
personality_custom: string
instructions: string
}
function asPersonalization(b: unknown): Personalization | undefined {
if (!b || typeof b !== "object") return undefined
const o = b as Record<string, unknown>
const str = (v: unknown) => (typeof v === "string" ? v : "")
return { enabled: o.enabled === true, available: o.available === true, personality: str(o.personality), personality_custom: str(o.personality_custom), instructions: str(o.instructions) }
}
/** The account's personalization, or undefined from an instance without the endpoint. */
export async function personalization(base: string, token: string, tls: Tls | undefined, timeoutMs = 5000): Promise<Personalization | undefined> {
const res = await call(`${base}/v1/me/personalization`, { headers: { authorization: `Bearer ${token}`, accept: "application/json" } }, tls, timeoutMs)
if (res.status === 404) return undefined
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
if (!res.ok) throw new LembasError(`reading the personalization: ${res.status} ${res.statusText}`)
return asPersonalization(await res.json().catch(() => undefined))
}
/** Write it back (the CLI's /settings, logged in). 403: not available, or a token that is not a
* device's; 422: a field of the wrong type. */
export async function savePersonalization(base: string, token: string, tls: Tls | undefined, value: Partial<Omit<Personalization, "available">>, timeoutMs = 10_000): Promise<Personalization | undefined> {
const res = await call(
`${base}/v1/me/personalization`,
{ method: "PUT", headers: { authorization: `Bearer ${token}`, accept: "application/json", "content-type": "application/json" }, body: JSON.stringify(value) },
tls,
timeoutMs,
)
// 403 is two things (not available, or not a device token): the instance's own words, where it
// says which; 422 a field it would not take.
if (res.status === 403 || res.status === 422) {
const detail = await res
.json()
.then((b: any) => (typeof b?.detail === "string" ? b.detail : typeof b?.error === "string" ? b.error : ""))
.catch(() => "")
if (res.status === 422) throw new LembasError(`the instance would not take that personalization${detail ? `: ${detail}` : ""}`)
throw new LembasError(detail ? `the instance did not allow it: ${detail}` : "not allowed: the instance refused to save the personalization")
}
if (res.status === 401) throw new LembasError("the instance no longer accepts this sign-in — run login again")
if (!res.ok) throw new LembasError(`saving the personalization: ${res.status} ${res.statusText}`)
return asPersonalization(await res.json().catch(() => undefined))
}
+465
View File
@@ -0,0 +1,465 @@
// /login and `lembas login`: sign this machine in to a LLeMbas instance, and point LLeMbas CLI at
// it — one `webui` connection, named after the instance (example), and voice and web search through
// it where the instance offers them.
//
// What is written is ordinary configuration, in the files rule 2 allows: one entry in the global
// connections.yaml holding the address and the key as {file:} to a 0600 file beside it, never
// pasted into the YAML. The models are not written anywhere: a webui connection reads them from the
// instance at every start (webui.ts). `lembas.json` remembers which connections a login made, so
// `login` again refreshes them and `logout` removes exactly them. Without a login nothing here is
// read, and LLeMbas CLI runs on its hand-written config as before.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { parseDocument } from "yaml"
import { paths } from "../config/paths.ts"
import { awaitToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
export interface Instance {
base_url: string
connection: string
email?: string
name?: string
/** The instance's own name (Example). */
instance_name?: string
ca?: string
/** What the token may do, as the instance granted it: `library` in it makes the account's
* library the default. An older login has none recorded until it is made again. */
scope?: string
/** The link protocols the instance listed when last asked: at login, and again at every
* dial of the link, so an instance updated later is picked up. */
protocols?: number[]
logged_in_at: string
}
interface Index {
instances: Record<string, Instance>
}
const indexFile = () => join(paths.config, "lembas.json")
const keyDir = () => join(paths.config, "lembas")
export const keyFile = (connection: string) => join(keyDir(), `${connection}.key`)
/** What a later look at the instance said it speaks, kept with the login. */
export function rememberProtocols(connection: string, protocols: number[]) {
const all = instances()
const one = all[connection]
if (!one || JSON.stringify(one.protocols) === JSON.stringify(protocols)) return
saveIndex({ ...all, [connection]: { ...one, protocols } })
}
export function instances(): Record<string, Instance> {
try {
const parsed = JSON.parse(readFileSync(indexFile(), "utf8")) as Index
return parsed.instances ?? {}
} catch {
return {}
}
}
function saveIndex(all: Record<string, Instance>) {
mkdirSync(paths.config, { recursive: true })
writeFileSync(indexFile(), JSON.stringify({ instances: all }, null, 2) + "\n", { mode: 0o600 })
}
type Doc = ReturnType<typeof parseDocument>
function readYamlDoc(file: string): Doc {
const doc = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "")
if (!doc.contents) doc.contents = doc.createNode({}) as never
return doc
}
const connectionsFile = () => join(paths.config, "connections.yaml")
const configFile = () => join(paths.config, "config.yaml")
function writeYamlDoc(file: string, doc: Doc, mode?: number) {
mkdirSync(paths.config, { recursive: true })
writeFileSync(file, doc.toString(), mode ? { mode } : undefined)
if (mode) chmodSync(file, mode)
}
/** What to call the instance's connection: the name it gives itself (`example`), or — from an
* instance that gives none — its host's first label. Never one the user wrote by hand:
* that gets `-lembas` after it. */
export function connectionNameFor(baseUrl: string, preferred?: string): string {
let name = (preferred ?? "").toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "")
if (!name) {
const host = new URL(baseUrl).hostname
const first = (/^\d+(\.\d+){3}$/.test(host) ? host.replace(/\./g, "-") : host.split(".")[0]!).toLowerCase()
name = first.replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "") || "lembas"
}
const ours = Object.values(instances()).some((i) => i.connection === name && i.base_url === baseUrl)
if (ours) return name
return readYamlDoc(connectionsFile()).hasIn(["connections", name]) ? `${name}-lembas` : name
}
export function writeConnection(connection: string, d: Discovery, tls: Tls | undefined) {
const doc = readYamlDoc(connectionsFile())
const entry: Record<string, unknown> = {
type: "webui",
url: d.base_url,
api_key: `{file:${keyFile(connection)}}`,
...(tls?.ca ? { tls: { ca: tls.ca } } : {}),
}
doc.setIn(["connections", connection], doc.createNode(entry))
const node = doc.getIn(["connections", connection], true) as { commentBefore?: string } | undefined
if (node)
node.commentBefore = ` ${d.name ?? "LLeMbas"} at ${d.base_url}, written by \`lembas login\`. Its models and their settings come from the instance at every start; \`lembas logout ${connection}\` removes it.`
writeYamlDoc(connectionsFile(), doc, 0o600)
}
/** A model ref `old/x` as `new/x`; anything else unchanged. */
function renamed(ref: unknown, from: string, to: string): unknown {
return typeof ref === "string" && ref.startsWith(`${from}/`) ? `${to}/${ref.slice(from.length + 1)}` : ref
}
/** An older kind of login named the connection after the host (`ai`) and copied every model
* into it. Logging in again replaces it with the webui one: the old entry, key and cache go, and
* config.yaml's model refs follow the new name. A start model that was only the instance's
* default goes too, so the instance decides it from now on. Returns what changed, in words. */
function replaceOld(old: string, now: string, instanceDefault: string | undefined): string[] {
const said: string[] = []
const conns = readYamlDoc(connectionsFile())
if (conns.hasIn(["connections", old])) {
conns.deleteIn(["connections", old])
writeYamlDoc(connectionsFile(), conns, 0o600)
}
rmSync(keyFile(old), { force: true })
dropCache(old)
said.push(`the connection ${old} from an earlier login is now ${now}`)
if (!existsSync(configFile())) return said
const cfg = readYamlDoc(configFile())
let changed = false
const model = cfg.get("model")
if (typeof model === "string" && model.startsWith(`${old}/`)) {
if (instanceDefault && model === `${old}/${instanceDefault}`) {
cfg.delete("model")
said.push(`config.yaml no longer pins ${model}: sessions start on the instance's default`)
} else {
cfg.set("model", renamed(model, old, now))
said.push(`config.yaml starts on ${renamed(model, old, now)}`)
}
changed = true
}
for (const key of ["small_model", "embedding"]) {
const v = cfg.get(key)
if (typeof v === "string" && v.startsWith(`${old}/`)) {
cfg.set(key, renamed(v, old, now))
changed = true
}
}
if (changed) writeYamlDoc(configFile(), cfg)
return said
}
/** Voice and web search through the instance, where it offers them and this machine does not
* already use it: what the config had becomes the fallback (voice) or comes after it (search).
* Returns what changed, in words. */
export function useServices(connection: string, services: InstanceInfo["services"], several: boolean): string[] {
if (!services) return []
const said: string[] = []
const cfg = readYamlDoc(configFile())
const plain = (path: string[]) => {
const v = cfg.getIn(path, true) as { toJSON?: () => unknown } | undefined
return v && typeof v.toJSON === "function" ? (v.toJSON() as Record<string, unknown>) : undefined
}
const named = several ? { connection } : {}
for (const [half, offered, words] of [
["stt", services.stt, "voice input"],
["tts", services.tts, "voice output"],
] as const) {
if (!offered) continue
const now = plain(["voice", half])
if (now?.provider === "webui") continue
const { fallback: _, ...before } = now ?? {}
cfg.setIn(["voice", half], cfg.createNode({ provider: "webui", ...named, ...(now ? { fallback: before } : {}) }))
said.push(`${words} through ${connection}${now ? ` (what was set up stays as its fallback)` : ""}`)
}
if (services.search) {
const search = plain(["search"]) ?? {}
const order = Array.isArray(search.order) ? (search.order as string[]) : undefined
if (!order?.includes("webui")) {
// What was tried before is tried after the instance, in the order it was.
const before = order ?? [...(search.searxng ? ["searxng"] : []), ...(search.firecrawl ? ["firecrawl"] : []), "ddg"]
cfg.setIn(["search", "order"], ["webui", ...before.filter((p) => p !== "webui")])
if (several) cfg.setIn(["search", "webui"], cfg.createNode({ connection }))
said.push(`web search through ${connection} first (then ${before.join(", ")})`)
}
}
if (said.length) writeYamlDoc(configFile(), cfg)
return said
}
/** Take a connection out of voice and search again: logout. */
function dropServices(connection: string, several: boolean): void {
if (!existsSync(configFile())) return
const cfg = readYamlDoc(configFile())
let changed = false
const ours = (o: Record<string, unknown> | undefined) => o?.provider === "webui" && (o.connection === connection || (!o.connection && !several))
for (const half of ["stt", "tts"]) {
const node = cfg.getIn(["voice", half], true) as { toJSON?: () => unknown } | undefined
const v = node && typeof node.toJSON === "function" ? (node.toJSON() as Record<string, unknown>) : undefined
if (!ours(v)) continue
if (v!.fallback) cfg.setIn(["voice", half], cfg.createNode(v!.fallback))
else cfg.deleteIn(["voice", half])
changed = true
}
const s = cfg.getIn(["search"], true) as { toJSON?: () => unknown } | undefined
const search = s && typeof s.toJSON === "function" ? (s.toJSON() as Record<string, unknown>) : undefined
const webui = search?.webui as { connection?: string } | undefined
if (search && (webui?.connection === connection || (!webui?.connection && !several))) {
if (Array.isArray(search.order) && search.order.includes("webui")) {
cfg.setIn(["search", "order"], (search.order as string[]).filter((p) => p !== "webui"))
changed = true
}
if (webui) {
cfg.deleteIn(["search", "webui"])
changed = true
}
if (search.fetch === "webui") {
cfg.deleteIn(["search", "fetch"])
changed = true
}
}
if (changed) writeYamlDoc(configFile(), cfg)
}
/** The library's embedding model from the instance: with no `embedding:` in config.yaml,
* the first model the instance serves as `kind: embedding`, as `<connection>/<served id>` — the
* form the embedder resolves whatever the provider is called (library/embed.ts). An embedding the
* person set — another instance's, or one of this machine's own — is never replaced: the vectors
* already made with it are only found again with it. Returns what changed, in words. */
export function useEmbedding(connection: string, models: ServedModel[]): string[] {
const first = models.find((m) => m.kind === "embedding")
if (!first) return []
const cfg = readYamlDoc(configFile())
const now = cfg.get("embedding")
if (typeof now === "string" && now.trim()) return []
const ref = `${connection}/${first.id}`
cfg.set("embedding", ref)
writeYamlDoc(configFile(), cfg)
return [`the library embeds with ${ref} (embedding:)`]
}
/** Logout: an `embedding:` on the connection that goes would only warn at every start. */
function dropEmbedding(connection: string) {
if (!existsSync(configFile())) return
const cfg = readYamlDoc(configFile())
const v = cfg.get("embedding")
if (typeof v !== "string" || !v.startsWith(`${connection}/`)) return
cfg.delete("embedding")
writeYamlDoc(configFile(), cfg)
}
function currentModel(): string | undefined {
if (!existsSync(configFile())) return undefined
const value = parseDocument(readFileSync(configFile(), "utf8")).get("model")
return typeof value === "string" && value ? value : undefined
}
/** Each served model's ref once this login is in place (by provider, `deepseek/x`; an
* instance that does not speak protocol 2, or a provider a connection of the user's own is named after, keeps
* `<connection>/<id>`). Read from the configuration as it now is. */
async function refsOf(connection: string): Promise<Record<string, string>> {
try {
const { loadConfig } = await import("../config/load.ts")
const loaded = loadConfig()
return Object.fromEntries(loaded.refs.filter((r) => r.connection === connection).map((r) => [r.id, r.ref]))
} catch {
return {}
}
}
export interface LoginResult {
connection: string
instance: Instance
models: ServedModel[]
/** The model a session starts on when config.yaml names none: the instance's default. */
instanceDefault?: string
/** The model ref config.yaml starts on, when login set it (--default). */
defaultSet?: string
/** What else changed — voice, search, an earlier login replaced — in words. */
changes: string[]
version: string
/** Each served id's ref. */
refs?: Record<string, string>
}
export interface LoginOptions {
/** The CA that signed the instance's certificate, when it is not one Bun trusts. */
ca?: string
/** Pin config.yaml's model to the instance's default (otherwise it follows the instance). */
setDefault?: boolean
/** Leave voice and web search as they are. */
keepServices?: boolean
signal?: AbortSignal
/** Called once with the code to show and where to approve it. */
onCode: (start: DeviceStart, d: Discovery) => void
sleep?: (ms: number) => Promise<void>
}
/** The two ways to approve a code, as the person is told them. */
export function codeInstructions(start: DeviceStart, d: Discovery): string {
const link = start.verification_uri_complete ?? start.verification_uri
const name = d.name ? `${d.name} (LLeMbas ${d.version})` : `LLeMbas ${d.version}`
return [
`Found ${name} at ${d.base_url}. To sign this machine in:`,
"",
` 1. Click the link: ${link}`,
` 2. Copy the code ${start.user_code} into User → Security → Devices`,
"",
`It lasts ${Math.round(start.expires_in / 60)} minutes. Waiting…`,
].join("\n")
}
/** Whether a login's connection is already the webui kind — not an older kind of entry, with
* the models copied in, which a login again replaces. */
export function isWebuiEntry(connection: string): boolean {
const doc = readYamlDoc(connectionsFile()).toJSON() as { connections?: Record<string, { type?: string }> } | null
return doc?.connections?.[connection]?.type === "webui"
}
/** What a login or a refresh did, as lines for a terminal. */
export function loginSummary(r: LoginResult, fresh: boolean): string[] {
const out: string[] = []
const who = r.instance.email ? ` as ${r.instance.email}` : ""
const label = r.instance.instance_name ? `${r.instance.instance_name} (${r.instance.base_url})` : r.instance.base_url
out.push(fresh ? `Signed in to ${label}${who}. Connection ${r.connection}: ${r.models.length} model${r.models.length === 1 ? "" : "s"}, read from the instance at every start.` : `${r.connection}: ${r.models.length} model${r.models.length === 1 ? "" : "s"} from ${label} (LLeMbas ${r.version}).`)
const refOf = (id: string) => r.refs?.[id] ?? `${r.connection}/${id}`
for (const m of r.models) out.push(` ${refOf(m.id)}${m.context ? ` ${Math.round(m.context / 1024)}k` : ""}${m.id === r.instanceDefault ? " (the instance's default)" : ""}`)
const model = currentModel()
if (r.defaultSet) out.push(`Sessions start on ${r.defaultSet}.`)
else if (!model && r.instanceDefault) out.push(`Sessions start on the instance's default, ${refOf(r.instanceDefault)}.`)
else if (model) out.push(`Sessions start on ${model} (config.yaml).`)
for (const c of r.changes) out.push(`· ${c}`)
if (r.instance.ca) out.push(`TLS: trusting ${r.instance.ca} for this connection.`)
return out
}
function webuiCount(): number {
const doc = readYamlDoc(connectionsFile()).toJSON() as { connections?: Record<string, { type?: string }> } | null
return Object.values(doc?.connections ?? {}).filter((c) => c?.type === "webui").length
}
export async function login(address: string, o: LoginOptions): Promise<LoginResult> {
const { discovery, tls } = await discover(address, o.ca ? { ca: o.ca } : undefined)
const start = await startDevice(discovery, tls)
o.onCode(start, discovery)
const granted = await awaitToken(discovery, start, tls, { signal: o.signal, sleep: o.sleep })
const connection = connectionNameFor(discovery.base_url, discovery.connection)
mkdirSync(keyDir(), { recursive: true, mode: 0o700 })
writeFileSync(keyFile(connection), granted.access_token + "\n", { mode: 0o600 })
chmodSync(keyFile(connection), 0o600)
const fetched = await fetchWebui(connection, discovery.base_url, granted.access_token, tls)
const instanceDefault = fetched.instance?.default_model ?? fetched.models.find((m) => m.default)?.id ?? undefined
const changes: string[] = []
const known = instances()
for (const [name, old] of Object.entries(known))
if (name !== connection && old.base_url === discovery.base_url) {
// The old token is signed out too: this machine holds one, the new one.
if (existsSync(keyFile(name))) await signOut(old.base_url, readFileSync(keyFile(name), "utf8").trim(), old.ca ? { ca: old.ca } : undefined)
changes.push(...replaceOld(name, connection, instanceDefault))
delete known[name]
}
writeConnection(connection, discovery, tls)
const instance: Instance = {
base_url: discovery.base_url,
connection,
email: granted.account?.email,
name: granted.account?.name,
...(discovery.name ? { instance_name: discovery.name } : {}),
...(tls?.ca ? { ca: tls.ca } : {}),
...(granted.scope ? { scope: granted.scope } : {}),
protocols: spoken(discovery),
logged_in_at: new Date().toISOString(),
}
saveIndex({ ...known, [connection]: instance })
if (!o.keepServices) changes.push(...useServices(connection, fetched.instance?.services, webuiCount() > 1))
changes.push(...useEmbedding(connection, fetched.models))
const refs = await refsOf(connection)
let defaultSet: string | undefined
if (o.setDefault && instanceDefault) {
const cfg = readYamlDoc(configFile())
defaultSet = refs[instanceDefault] ?? `${connection}/${instanceDefault}`
cfg.set("model", defaultSet)
writeYamlDoc(configFile(), cfg)
}
// Chat models only: what the summary lists is what /model offers.
return { connection, instance, models: fetched.models.filter(isChatModel), instanceDefault, defaultSet, changes, version: discovery.version, refs }
}
function tokenOf(connection: string): string {
try {
return readFileSync(keyFile(connection), "utf8").trim()
} catch {
throw new LembasError(`no stored sign-in for ${connection} — run lembas login`)
}
}
/** Read the account's models again now (every start does this anyway). */
export async function sync(connection: string, o: { setDefault?: boolean } = {}): Promise<LoginResult> {
const instance = instances()[connection]
if (!instance) throw new LembasError(`${connection} was not made by lembas login`)
const { discovery, tls } = await discover(instance.base_url, instance.ca ? { ca: instance.ca } : undefined)
const fetched = await fetchWebui(connection, discovery.base_url, tokenOf(connection), tls)
const instanceDefault = fetched.instance?.default_model ?? fetched.models.find((m) => m.default)?.id ?? undefined
// Logging in again reads the models again: an embedding model the instance has added since is
// taken up the same way, still only where none is set.
const changes = useEmbedding(connection, fetched.models)
const refs = await refsOf(connection)
let defaultSet: string | undefined
if (o.setDefault && instanceDefault) {
const cfg = readYamlDoc(configFile())
defaultSet = refs[instanceDefault] ?? `${connection}/${instanceDefault}`
cfg.set("model", defaultSet)
writeYamlDoc(configFile(), cfg)
}
return { connection, instance, models: fetched.models.filter(isChatModel), instanceDefault, defaultSet, changes, version: discovery.version, refs }
}
export interface LogoutResult {
connection: string
revoked: boolean
/** config.yaml still names a model on the removed connection. */
modelLeft?: string
}
export async function logout(connection: string): Promise<LogoutResult> {
const all = instances()
const instance = all[connection]
if (!instance) throw new LembasError(`${connection} was not made by lembas login${Object.keys(all).length ? ` — logged in: ${Object.keys(all).join(", ")}` : ""}`)
// Whether config.yaml starts on one of its models — by provider (`deepseek/x`) the ref
// no longer names the connection, so it is asked before the connection goes.
let ours = false
try {
const { loadConfig } = await import("../config/load.ts")
const { findRef } = await import("../provider/refs.ts")
const loaded = loadConfig()
const m = currentModel()
ours = Boolean(m && findRef(loaded.connections, loaded.refs, m)?.connection === connection)
} catch {}
let revoked = false
if (existsSync(keyFile(connection))) {
revoked = await signOut(instance.base_url, tokenOf(connection), instance.ca ? { ca: instance.ca } : undefined)
rmSync(keyFile(connection), { force: true })
}
const several = webuiCount() > 1
const doc = readYamlDoc(connectionsFile())
if (doc.hasIn(["connections", connection])) {
doc.deleteIn(["connections", connection])
writeYamlDoc(connectionsFile(), doc, 0o600)
}
dropCache(connection)
dropServices(connection, several)
dropEmbedding(connection)
delete all[connection]
saveIndex(all)
const model = currentModel()
return { connection, revoked, modelLeft: model && (ours || model.startsWith(`${connection}/`)) ? model : undefined }
}
export { LembasError, readCache, specFor }
+142
View File
@@ -0,0 +1,142 @@
// What the account on a LLeMbas instance says about the person: how they want to be
// helped, and the library. Logged in, one account is one person in both places — the web UI and
// this terminal read and write the same three settings, so a personality chosen in either is the
// one both use. Logged out (or on an instance that does not offer it), the local keys apply as
// before, and nothing here is read.
//
// personality, personality_custom, instructions — from GET /v1/me/personalization when the
// instance has it `available` (its administrator allows it); /settings writes them back there
// with PUT. Read with the models at every start (webui.ts) and kept in the same cache.
// library — with no `library:` written anywhere, a login whose token has the `library` scope
// means the account's library (`lembas`); `library: local` written keeps this machine's.
import { existsSync, readFileSync } from "node:fs"
import type { Config } from "../config/schema.ts"
import { PERSONALITIES } from "../config/schema.ts"
import { LembasError, savePersonalization, usage, type Personalization, type Usage } from "./client.ts"
import { instances, keyFile, type Instance } from "./login.ts"
import { readCache, writeCache } from "./webui.ts"
/** The one instance this machine is logged in to, with its key; undefined with none or several. */
export function soleInstance(): { instance: Instance; token: string } | undefined {
const all = Object.values(instances())
if (all.length !== 1) return undefined
const instance = all[0]!
if (!existsSync(keyFile(instance.connection))) return undefined
try {
return { instance, token: readFileSync(keyFile(instance.connection), "utf8").trim() }
} catch {
return undefined
}
}
/** §10: the library a config with no `library:` uses — the account's, when the login may read it. */
export function defaultLibrary(): "lembas" | undefined {
const one = soleInstance()
return one?.instance.scope?.split(/\s+/).includes("library") ? "lembas" : undefined
}
/** The instance's personalization as last read, when it is the one that counts. */
export function instancePersonalization(): { connection: string; value: Personalization } | undefined {
const one = soleInstance()
if (!one) return undefined
const p = readCache(one.instance.connection)?.personalization
return p?.available ? { connection: one.instance.connection, value: p } : undefined
}
export const PERSONAL_KEYS = ["personality", "personality_custom", "instructions"] as const
/** The three keys as the instance has them, put over the config's: what the session uses. Off on
* the instance (the person opted out there) is none of them. */
export function withInstancePersonalization(config: Config): { config: Config; connection?: string } {
const p = instancePersonalization()
if (!p) return { config }
return { connection: p.connection, config: { ...config, ...personalConfig(p.value) } }
}
/** Write one of the three back to the instance (/settings, logged in): that one field, and on —
* choosing it is opting in. Only what the person set is sent: the other two are the instance's as
* saved there (the session may have blanked them, an opted-out account, or not know a preset the
* web UI has), and a PUT of the session's copy would wipe them. The cache takes what the instance
* answered. */
export async function pushPersonalization(key: (typeof PERSONAL_KEYS)[number], value: string): Promise<Personalization | undefined> {
const one = soleInstance()
if (!one) throw new LembasError("not logged in to one LLeMbas instance")
const saved = await savePersonalization(one.instance.base_url, one.token, one.instance.ca ? { ca: one.instance.ca } : undefined, { enabled: true, [key]: value })
const cache = readCache(one.instance.connection)
if (cache) writeCache(one.instance.connection, { ...cache, personalization: saved ?? { ...(cache.personalization ?? { personality: "", personality_custom: "", instructions: "", available: true }), [key]: value, enabled: true } })
return saved
}
/** The three keys a session uses for what the instance says (an unknown preset is none here, but
* stays the instance's). */
export function personalConfig(v: Personalization): Pick<Config, "personality" | "personality_custom" | "instructions"> {
const preset = v.enabled && (PERSONALITIES as readonly string[]).includes(v.personality) ? (v.personality as Config["personality"]) : ""
return { personality: preset, personality_custom: v.enabled ? v.personality_custom.slice(0, 1500) : "", instructions: v.enabled ? v.instructions.slice(0, 4000) : "" }
}
/** The account's usage on the one instance logged in to, or undefined: not logged in, or
* an instance without /v1/usage. */
export async function accountUsage(timeoutMs?: number): Promise<{ connection: string; usage: Usage } | undefined> {
const one = soleInstance()
if (!one) return undefined
const u = await usage(one.instance.base_url, one.token, one.instance.ca ? { ca: one.instance.ca } : undefined, timeoutMs)
return u ? { connection: one.instance.connection, usage: u } : undefined
}
const n = (x: number) => Math.round(x).toLocaleString("en")
/** Credits as a person reads them: two decimals where the number is small enough for them
* to matter (a balance of 3.47 is not "3"), whole above a thousand, as before. */
export function credits(x: number): string {
return x.toLocaleString("en", { maximumFractionDigits: Math.abs(x) < 1000 ? 2 : 0 })
}
/** An amount in the instance's currency: `€1.23`, `$1.23`, `£1.23` — or "" without one. */
export function money(x: number | null | undefined, currency: string | undefined): string {
if (x === null || x === undefined || !Number.isFinite(x) || !currency) return ""
try {
return new Intl.NumberFormat("en", { style: "currency", currency }).format(x)
} catch {
// A code Intl does not know: the number and the code, rather than nothing.
return `${x.toFixed(2)} ${currency}`
}
}
/** A credit amount's money, as the instance gave it, else from what a credit is worth. */
function moneyOf(u: Usage, given: number | null | undefined, amount: number | null | undefined): string {
if (given !== null && given !== undefined) return money(given, u.currency)
if (amount === null || amount === undefined || typeof u.credit_value !== "number") return ""
return money(amount * u.credit_value, u.currency)
}
const withMoney = (text: string, m: string) => (m ? `${text} (${m})` : text)
/** The status bar's word: the balance, when the account has a plan, with its money when the
* instance says it; `unlimited` for an administrator; "" otherwise. */
export function balanceLabel(u: Usage | undefined): string {
if (u?.admin === true) return "unlimited"
if (!u?.plan || u.balance === null || u.balance === undefined) return ""
return withMoney(`${credits(u.balance)} ${u.unit || "credits"}`, moneyOf(u, u.balance_money, u.balance))
}
/** /usage: the detail, in lines. */
export function usageLines(connection: string, u: Usage): string[] {
const unit = u.unit || "credits"
const amount = (c: number | null | undefined, m: number | null | undefined) => (c !== null && c !== undefined ? ` — ${withMoney(`${credits(c)} ${unit}`, moneyOf(u, m, c))}` : m !== null && m !== undefined ? ` — ${money(m, u.currency)}` : "")
const head = u.admin === true
? `${connection}: unlimited (administrator)`
: `${connection}: ${u.plan ? `plan ${u.plan.name}, ${n(u.plan.credits_per_month)} ${unit} a month` : "no plan"}${u.balance !== null && u.balance !== undefined ? ` · balance ${withMoney(`${credits(u.balance)} ${unit}`, moneyOf(u, u.balance_money, u.balance))}` : ""}`
const out = [head]
const since = u.month?.from ? u.month.from.slice(0, 10) : "the start of the month"
if (u.month) out.push(`this month (since ${since}): ${n(u.month.tokens_in)} tokens in, ${n(u.month.tokens_out)} out${amount(u.month.cost, u.month.cost_money)}`)
if (u.device) out.push(`this device: ${n(u.device.tokens_in)} in, ${n(u.device.tokens_out)} out${amount(u.device.cost, u.device.cost_money)}`)
// The rolling windows: an administrator has none that bind, so none are listed.
if (u.admin !== true)
for (const w of Array.isArray(u.windows) ? u.windows : []) {
if (!w || typeof w.spent !== "number") continue
const frees = typeof w.frees_at === "string" && w.frees_at ? `, frees from ${w.frees_at.slice(0, 16).replace("T", " ")}` : ""
const of = typeof w.limit === "number" ? ` of ${credits(w.limit)}` : ""
out.push(`window ${w.window}: ${credits(w.spent)}${of} ${unit} spent${frees}`)
}
return out
}
+219
View File
@@ -0,0 +1,219 @@
// The `webui` connection: one entry in connections.yaml that names a LLeMbas instance and
// nothing else —
//
// example:
// type: webui
// url: https://ai.example.org
// api_key: "{file:~/.config/lembas/lembas/example.key}"
//
// The models are the instance's, read from its /v1/models with the settings its administrator gave
// them (context, efforts, sampling, capacity), and so are the voice and the web search when
// config.yaml says `provider: webui`. Nothing is copied into the config: a model added, removed or
// given another context on the instance is here at the next start, without logging in again.
//
// LLeMbas CLI speaks to it as an ordinary openai-chat connection at <url>/v1. What the instance said
// last is kept in the state directory, so a start without the network still knows the models; a
// start that can reach the instance reads them again first (`refreshWebui`).
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { parse } from "yaml"
import { paths } from "../config/paths.ts"
import type { Connection, ModelSpec, WebuiConnection } from "../config/schema.ts"
import { runKeyCmd, substituteDeep } from "../config/substitute.ts"
import { instanceInfo, LembasError, models as listModels, normalise, personalization, type InstanceInfo, type Personalization, type ServedModel, type Tls } from "./client.ts"
/** What a loaded webui connection carries besides an openai-chat connection's fields. */
export interface WebuiMark {
/** The instance's base address, without /v1. */
url: string
/** The model this account's new chats start on there, as the connection's model id. */
default?: string
/** The instance's name (Example), when it said. */
name?: string
services?: InstanceInfo["services"]
/** When the models were last read from the instance; absent: never. */
fetched_at?: string
/** Each served model's provider, where the instance said it (`provider`). */
providers?: Record<string, string>
/** The served ids of its embedding models, which are not chat models and so not in
* `models`: `embedding: llama/nomic-embed` finds its connection by them (library/embed.ts). */
embeddings?: string[]
/** The instance speaks link protocol 2: its ids are `<provider>/<model>` even
* where a model does not say its provider. Set by loadConfig from the login. */
v2?: boolean
}
export type WebuiLoaded = Connection & { webui: WebuiMark }
export interface Cache {
fetched_at: string
url: string
instance?: InstanceInfo
models: ServedModel[]
/** The account's personalization, where the instance has the endpoint. */
personalization?: Personalization
}
const cacheDir = () => join(paths.state, "webui")
export const cacheFile = (connection: string) => join(cacheDir(), `${connection}.json`)
export function readCache(connection: string): Cache | undefined {
try {
const c = JSON.parse(readFileSync(cacheFile(connection), "utf8")) as Cache
return Array.isArray(c.models) ? c : undefined
} catch {
return undefined
}
}
export function writeCache(connection: string, cache: Cache) {
mkdirSync(cacheDir(), { recursive: true, mode: 0o700 })
writeFileSync(cacheFile(connection), JSON.stringify(cache, null, 2) + "\n", { mode: 0o600 })
}
export function dropCache(connection: string) {
rmSync(cacheFile(connection), { force: true })
}
/** A served model a session can talk to: `kind` chat, or none (an instance that does not
* say `kind` lists nothing else). The others — embedding, stt, tts, image — are no chat model: listed
* as one, the model picker offered a speech model to talk to. The embedding ones are the
* library's to use (login.ts, useEmbedding); voice reaches stt and tts through the instance's own
* endpoints. */
export function isChatModel(m: ServedModel): boolean {
// Anything but the four non-chat kinds is chat: an instance may put the connection's dialect
// ("openai", "anthropic") in `kind`, and reading that as "not chat" would leave such an instance
// with no models at all.
return !NOT_CHAT.has(String(m.kind ?? ""))
}
const NOT_CHAT = new Set(["embedding", "stt", "tts", "image"])
/** One served model as a LLeMbas CLI model entry. Only what the instance said: an absent field
* stays absent, because unknown is not zero. The capacity group is prefixed with the connection,
* so the instance's "gpu" is never mistaken for a group of the user's own. */
export function specFor(m: ServedModel, connection: string): ModelSpec {
const out: Record<string, unknown> = {}
if (m.name && m.name !== m.id) out.name = m.name
if (m.family) out.family = m.family
if (m.context) out.context = m.context
if (m.max_output) out.max_output = m.max_output
if (typeof m.temperature === "number" && m.temperature >= 0 && m.temperature <= 2) out.temperature = m.temperature
if (typeof m.top_p === "number" && m.top_p >= 0 && m.top_p <= 1) out.top_p = m.top_p
if (m.efforts?.length) out.efforts = m.efforts
if (m.effort) out.effort = m.effort
if (m.vision !== undefined) out.vision = m.vision
if (m.tools !== undefined) out.tools = m.tools
if (m.notes) out.notes = m.notes.slice(0, 300)
if (m.capacity?.single_session) out.single_session = true
if (m.capacity?.group) out.group = `${connection}:${m.capacity.group}`
return out as ModelSpec
}
/** A webui entry as the openai-chat connection LLeMbas CLI speaks to, with the instance's models
* (the account's default first) and anything the entry's own `models:` says on top. */
export function expandWebui(name: string, w: WebuiConnection, cache = readCache(name)): WebuiLoaded {
const url = normalise(w.url)
// The cache keeps every kind (login picks the embedding model from it); the connection only chat.
const all = cache && cache.url === url ? cache.models : []
const served = all.filter(isChatModel)
const embeddings = all.filter((m) => m.kind === "embedding").map((m) => m.id)
const def = cache?.instance?.default_model ?? served.find((m) => m.default)?.id
const ordered = [...served].sort((a, b) => Number(b.id === def) - Number(a.id === def))
// The entry's own `models:` go over the instance's — keyed by the served id, or by the bare name an
// older entry used (`deepseek-flash` for the served `deepseek/deepseek-flash`): the
// override lands on the real model, not on a model of its own nobody serves.
const overrides = new Map<string, ModelSpec>()
const unmatched: [string, ModelSpec][] = []
for (const [key, spec] of Object.entries(w.models ?? {})) {
const target = served.some((m) => m.id === key) ? key : served.filter((m) => m.id.slice(m.id.indexOf("/") + 1) === key && m.id.includes("/"))
if (typeof target === "string") overrides.set(target, { ...overrides.get(target), ...spec })
else if (target.length === 1) overrides.set(target[0]!.id, { ...spec, ...overrides.get(target[0]!.id) })
else unmatched.push([key, spec])
}
const models: Record<string, ModelSpec> = {}
for (const m of ordered) models[m.id] = { ...specFor(m, name), ...(overrides.get(m.id) ?? {}) }
// A model the entry names that the instance does not (yet) list: kept, as written.
for (const [id, spec] of unmatched) models[id] ??= spec
const providers = Object.fromEntries(served.filter((m) => m.provider && m.id.startsWith(`${m.provider}/`)).map((m) => [m.id, m.provider!]))
return {
dialect: "openai-chat",
base_url: `${url}/v1`,
...(w.api_key !== undefined ? { api_key: w.api_key } : {}),
...(w.key_cmd !== undefined ? { key_cmd: w.key_cmd } : {}),
...(w.tls ? { tls: w.tls } : {}),
...(w.timeout ? { timeout: w.timeout } : {}),
...(w.quirks ? { quirks: w.quirks } : {}),
// The instance decides which models exist: one it serves that the cache does not know yet is
// still a model, not an error.
discover: true,
models,
webui: {
url,
...(def && models[def] ? { default: def } : {}),
...(cache?.instance?.name ? { name: cache.instance.name } : {}),
...(cache?.instance?.services ? { services: cache.instance.services } : {}),
...(cache && cache.url === url ? { fetched_at: cache.fetched_at } : {}),
...(Object.keys(providers).length ? { providers } : {}),
...(embeddings.length ? { embeddings } : {}),
},
}
}
export function isWebui(c: unknown): c is WebuiLoaded {
return !!c && typeof c === "object" && "webui" in c && !!(c as WebuiLoaded).webui
}
/** Read the instance's models and services, and keep them. Throws LembasError. */
export async function fetchWebui(name: string, url: string, token: string, tls: Tls | undefined, timeoutMs?: number): Promise<Cache> {
const base = normalise(url)
const [served, instance, personal] = await Promise.all([
listModels({ api: { openai: `${base}/v1` } }, token, tls, timeoutMs),
instanceInfo(base, token, tls, timeoutMs),
// Best-effort: an instance may not have it, and a failure here must not cost the models.
personalization(base, token, tls, timeoutMs).catch(() => undefined),
])
const cache: Cache = { fetched_at: new Date().toISOString(), url: base, ...(instance ? { instance } : {}), models: served, ...(personal ? { personalization: personal } : {}) }
writeCache(name, cache)
return cache
}
/** This process's last refresh that failed, by connection: the loader says so, beside the models
* it is using instead. */
export const refreshFailures: Record<string, string> = {}
export interface Refreshed {
name: string
ok: boolean
error?: string
models?: number
}
/** Every webui connection in connections.yaml, read again from its instance — what a start does
* first, so the models are the instance's as they are now. Never throws: an instance that cannot
* be reached leaves what was read last, and says so. */
export async function refreshWebui(o: { timeoutMs?: number; only?: string } = {}): Promise<Refreshed[]> {
const file = join(paths.config, "connections.yaml")
if (!existsSync(file)) return []
let raw: Record<string, unknown>
try {
raw = ((parse(readFileSync(file, "utf8")) ?? {}) as { connections?: Record<string, unknown> }).connections ?? {}
} catch {
return []
}
const entries = Object.entries(raw).filter(([name, c]) => (!o.only || o.only === name) && (c as { type?: string })?.type === "webui")
return Promise.all(
entries.map(async ([name, c]): Promise<Refreshed> => {
try {
const w = substituteDeep(c, `connections.${name}`) as WebuiConnection
const token = w.api_key ?? (w.key_cmd ? runKeyCmd(w.key_cmd, `connections.${name}.key_cmd`) : undefined)
if (!token) throw new LembasError("no key (lembas login)")
const got = await fetchWebui(name, w.url, token.trim(), w.tls, o.timeoutMs ?? 4000)
delete refreshFailures[name]
return { name, ok: true, models: got.models.length }
} catch (e) {
refreshFailures[name] = (e as Error).message
return { name, ok: false, error: (e as Error).message }
}
}),
)
}
+44
View File
@@ -0,0 +1,44 @@
// Text in pieces for searching, after LLeMbas services/library/chunks.py (© Jaroslav Beneš,
// MIT): about `size` characters each, overlapping by `overlap`, cut where the text
// breaks — a paragraph, else a line, else a sentence — looked for only in the last third of the
// window, so a piece is never much shorter than asked. Pieces under 40 characters are dropped.
export const CHUNK_CHARS = 1200
export const CHUNK_OVERLAP = 150
const MIN_CHUNK = 40
const MIN_SIZE = 200
export function split(text: string, size = CHUNK_CHARS, overlap = CHUNK_OVERLAP): string[] {
const t = text.replace(/\r\n/g, "\n").trim()
if (!t) return []
// The harness spec's rule (conformance/chunk.json), LLeMbas's: a size under 200 is raised to it,
// and the overlap is at most half the size — at or past it, every piece would start where the
// last one did and the loop would never advance.
size = Math.max(MIN_SIZE, Math.floor(size))
overlap = Math.max(0, Math.min(Math.floor(overlap), Math.floor(size / 2)))
// A short text is one piece, however short: it is still somebody's document.
if (t.length <= size) return [t]
const out: string[] = []
let start = 0
while (start < t.length) {
let end = Math.min(start + size, t.length)
if (end < t.length) end = boundary(t, start, end)
const piece = t.slice(start, end).trim()
if (piece.length >= MIN_CHUNK) out.push(piece)
if (end >= t.length) break
start = Math.max(end - overlap, start + 1)
}
return out
}
/** Where to cut: just after a paragraph break, else a line break, else a sentence's ". ", searched
* backwards and only in the window's last third — a break near the start would make a piece a
* fraction of the size. */
function boundary(t: string, start: number, end: number): number {
const floor = start + Math.floor(((end - start) * 2) / 3)
for (const marker of ["\n\n", "\n", ". "]) {
const found = t.lastIndexOf(marker, end - marker.length)
if (found > floor) return found + marker.length
}
return end
}
+117
View File
@@ -0,0 +1,117 @@
// `lembas kb`: knowledge bases from the shell — make one, put files, directories and pages
// into it, see what is there, search it, take things out.
import { loadConfig } from "../config/load.ts"
import { findProject, trustOf } from "../project/root.ts"
import { embedderFor } from "./embed.ts"
import { ingest } from "./ingest.ts"
import { Library, type Embedder } from "./store.ts"
export const KB_HELP = `lembas kb the knowledge bases
lembas kb create <name> [description]
lembas kb add <name> <file|directory|url>…
lembas kb docs <name> what a base holds
lembas kb search <query> [--base name]
lembas kb rm <name> [document-id…] a base (asks), or documents from it
lembas kb reindex embed what has no vector from the embedding model yet`
const kb = (n: number) => `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
export async function kbCommand(args: string[]): Promise<number> {
const lib = new Library()
const p = findProject(process.cwd())
const loaded = loadConfig({ projectConfigDir: p.dir, trusted: trustOf(p.root) === "trusted" })
let embedder: Embedder | undefined
try {
embedder = embedderFor(loaded, loaded.config.embedding)
} catch (e) {
console.error(`⚠ ${(e as Error).message}`)
}
const [sub = "list", ...rest] = args
const flag = (name: string) => {
const i = rest.indexOf(name)
if (i < 0) return undefined
const v = rest[i + 1]
rest.splice(i, 2)
return v
}
const embed = async () => {
if (!embedder) return
process.stdout.write(`embedding with ${embedder.model}… `)
try {
console.log(`${await lib.embedAll(embedder)} pieces`)
} catch (e) {
console.log(`failed: ${(e as Error).message} — searching works by words until lembas kb reindex succeeds`)
}
}
switch (sub) {
case "list": {
const all = lib.bases()
if (!all.length) console.log("no knowledge bases yet — lembas kb create <name>, then lembas kb add <name> <files>")
for (const b of all) console.log(`${b.name} ${b.documents} document${b.documents === 1 ? "" : "s"}${b.description ? ` — ${b.description}` : ""}`)
console.log(`\nsearch: ${embedder ? `words and meaning (${embedder.model})` : "words (set embedding: in config.yaml for meaning too)"}${loaded.config.knowledge ? ` · this project searches: ${loaded.config.knowledge.join(", ")}` : ""}`)
return 0
}
case "create": {
const [name, ...desc] = rest
if (!name) return usage()
lib.createBase(name, desc.join(" "))
console.log(`made ${name} — lembas kb add ${name} <files, directories, urls>`)
return 0
}
case "add": {
const [name, ...inputs] = rest
if (!name || !inputs.length) return usage()
const added = await ingest(lib, name, inputs, {
progress: (a) => console.log(`${a.error ? "✗" : a.changed ? "+" : "="} ${a.source}${a.error ? ` — ${a.error}` : ` ${kb(a.chars ?? 0)} chars${a.truncated ? " (cut at 120k)" : ""}${a.changed ? "" : " (unchanged)"}`}`),
})
console.log(`${added.filter((a) => a.changed).length} added or changed, ${added.filter((a) => !a.changed && !a.error).length} unchanged, ${added.filter((a) => a.error).length} with no text`)
await embed()
return 0
}
case "docs": {
const [name] = rest
if (!name || !lib.base(name)) return usage(name ? `there is no knowledge base "${name}"` : undefined)
for (const d of lib.documents(name)) console.log(`[${d.id}] ${d.title} ${d.error ? `✗ ${d.error}` : `${kb(d.chars)} chars${d.truncated ? " (cut)" : ""}`}${d.source !== d.title ? ` ${d.source}` : ""}`)
return 0
}
case "search": {
const base = flag("--base")
const q = rest.join(" ")
if (!q) return usage()
const hits = await lib.search(q, { bases: base ? [base] : loaded.config.knowledge, embedder }).catch(() => lib.search(q, { bases: base ? [base] : loaded.config.knowledge }))
if (!hits.length) console.log("nothing found")
for (const h of hits) console.log(`[${h.id}] ${h.title} (${h.base})\n ${h.snippet.replace(/\s+/g, " ").slice(0, 240)}\n`)
return 0
}
case "rm": {
const [name, ...ids] = rest.filter((a) => a !== "--yes")
if (!name || !lib.base(name)) return usage(name ? `there is no knowledge base "${name}"` : undefined)
if (ids.length) {
for (const id of ids) lib.deleteDocument(Number(id))
console.log(`removed ${ids.length} document${ids.length === 1 ? "" : "s"} from ${name}`)
return 0
}
if (!args.includes("--yes")) {
if (!process.stdin.isTTY) return usage(`removing the whole base ${name} needs --yes`)
process.stdout.write(`remove the knowledge base ${name} and its ${lib.base(name)!.documents} documents? [y/N] `)
const answer = await new Promise<string>((r) => process.stdin.once("data", (d) => r(d.toString().trim())))
process.stdin.pause()
if (!/^y/i.test(answer)) return 1
}
lib.deleteBase(name)
console.log(`removed ${name}`)
return 0
}
case "reindex":
if (!embedder) return usage("no embedding model is set (embedding: in config.yaml)")
await embed()
return 0
}
return usage()
}
function usage(message?: string): number {
if (message) console.error(`lembas kb: ${message}`)
else console.error(KB_HELP)
return 2
}
+60
View File
@@ -0,0 +1,60 @@
// Embeddings for the library: `embedding: connection/model` in config.yaml, over the connection's
// OpenAI-shaped /embeddings (llama.cpp with --embedding, vLLM, LM Studio, OpenAI…) or Ollama's
// /api/embed. Vectors are made unit length here, so a dot product is the cosine.
import { resolveKey, type Loaded } from "../config/load.ts"
import { authHeaders, joinUrl, request, tlsFor } from "../provider/http.ts"
import { resolveModel } from "../provider/index.ts"
import type { Embedder } from "./store.ts"
function unit(v: number[]): Float32Array {
let n = 0
for (const x of v) n += x * x
n = Math.sqrt(n) || 1
return Float32Array.from(v, (x) => x / n)
}
export function embedderFor(loaded: Loaded, ref: string | undefined): Embedder | undefined {
if (!ref) return undefined
// Through the same resolver as every model ref: an instance's model by its provider
// (`llama/nomic-embed`) or in the older form (`example/nomic-embed`). The name stored with the
// vectors stays the ref as written, so a library indexed under it is still found.
// An embedding model need not be listed on its connection (`connection/<any id>`, as before).
let name: string
let id: string
try {
const resolved = resolveModel(loaded, ref)
name = resolved.connectionName
id = resolved.id
} catch (e) {
// An instance's embedding model by its served id (`llama/nomic-embed`): not a
// model ref, since only chat models are (lembas/webui.ts, isChatModel), so found by the ids
// its connection says it serves.
const served = Object.entries(loaded.connections).find(([, c]) => (c as { webui?: { embeddings?: string[] } }).webui?.embeddings?.includes(ref))
const slash = ref.indexOf("/")
if (served) {
name = served[0]
id = ref
} else {
if (slash <= 0) throw new Error(`embedding: "${ref}" must be written connection/model`)
name = ref.slice(0, slash)
id = ref.slice(slash + 1)
if (!loaded.connections[name]) throw new Error(`embedding: ${(e as Error).message}`)
}
}
const c = loaded.connections[name]!
if (c.dialect === "anthropic" || c.dialect === "gemini") throw new Error(`embedding: the ${c.dialect} dialect has no embeddings here; use an OpenAI-compatible or Ollama connection`)
const ollama = c.dialect === "ollama"
return {
model: ref,
async embed(texts, signal) {
const res = await request(
joinUrl(c.base_url, ollama ? "api/embed" : "embeddings"),
{ method: "POST", headers: authHeaders(c, resolveKey(name, c)), body: JSON.stringify({ model: id, input: texts }), signal, timeoutMs: (c.timeout ?? 120) * 1000, tls: tlsFor(c) },
`${ref} (embeddings)`,
)
const j = (await res.json()) as { data?: { embedding: number[]; index?: number }[]; embeddings?: number[][] }
const rows = ollama ? (j.embeddings ?? []) : [...(j.data ?? [])].sort((a, b) => (a.index ?? 0) - (b.index ?? 0)).map((d) => d.embedding)
return rows.map(unit)
},
}
}
+115
View File
@@ -0,0 +1,115 @@
// Putting things into a knowledge base: a file, every readable file under a directory (what git
// tracks, in a repository; otherwise what is not hidden or vendored), or a web page. Text and code
// as they are; HTML as markdown; a PDF through pdftotext when it is installed. LLeMbas's limits:
// 20 MB a file, 120,000 characters of text a document (the rest is cut, and said).
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs"
import { basename, extname, join, relative, resolve } from "node:path"
import { fetchPage, htmlToMarkdown } from "../search/fetch.ts"
import type { Library } from "./store.ts"
export const MAX_FILE_BYTES = 20 * 1024 * 1024
export const MAX_TEXT_CHARS = 120_000
const MAX_FILES = 5000
const SKIP_DIRS = new Set([".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", "target", ".next", ".cache"])
const BINARY = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".zip", ".gz", ".tar", ".7z", ".so", ".dylib", ".dll", ".exe", ".bin", ".wasm", ".mp3", ".mp4", ".wav", ".ogg", ".woff", ".woff2", ".ttf", ".otf", ".db", ".sqlite", ".pyc", ".class", ".jar"])
export interface Added {
source: string
id?: number
changed?: boolean
chars?: number
truncated?: boolean
error?: string
}
function cap(text: string): { text: string; truncated: boolean } {
return text.length > MAX_TEXT_CHARS ? { text: text.slice(0, MAX_TEXT_CHARS), truncated: true } : { text, truncated: false }
}
/** A file's text, or why there is none. */
export function readText(path: string): { text?: string; error?: string } {
const size = statSync(path).size
if (size > MAX_FILE_BYTES) return { error: `${Math.round(size / 1048576)} MB — over the 20 MB a file may be` }
const ext = extname(path).toLowerCase()
if (BINARY.has(ext)) return { error: "not a text file" }
if (ext === ".pdf") {
const r = Bun.spawnSync(["pdftotext", "-layout", "-enc", "UTF-8", path, "-"], { stdout: "pipe", stderr: "pipe", timeout: 120_000 })
if (r.exitCode === null || r.exitCode === undefined || (r as { error?: unknown }).error) return { error: "a PDF needs pdftotext (sudo apt install poppler-utils)" }
if (r.exitCode !== 0) return { error: `pdftotext: ${r.stderr.toString().trim().slice(0, 200) || `exit ${r.exitCode}`}` }
const text = r.stdout.toString().trim()
return text ? { text } : { error: "the PDF has no text layer (a scan?) — no OCR here" }
}
const bytes = readFileSync(path)
if (bytes.subarray(0, 8000).includes(0)) return { error: "not a text file" }
const raw = new TextDecoder("utf-8", { fatal: false }).decode(bytes)
if (ext === ".html" || ext === ".htm") return { text: htmlToMarkdown(raw) }
return { text: raw }
}
/** The files to take from a directory: git's list where it is a repository, else a walk. */
function filesUnder(dir: string): string[] {
const git = Bun.spawnSync(["git", "-C", dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { stdout: "pipe", stderr: "ignore" })
if (git.exitCode === 0) return git.stdout.toString().split("\0").filter(Boolean).slice(0, MAX_FILES).map((f) => join(dir, f))
const out: string[] = []
for (const f of new Bun.Glob("**/*").scanSync({ cwd: dir, dot: false, followSymlinks: false })) {
if (f.split("/").some((p) => SKIP_DIRS.has(p))) continue
out.push(join(dir, f))
if (out.length >= MAX_FILES) break
}
return out
}
/** Add files, directories and URLs to a base. Each one says what became of it. */
export async function ingest(lib: Library, base: string, inputs: string[], o: { cwd?: string; signal?: AbortSignal; progress?: (a: Added) => void } = {}): Promise<Added[]> {
if (!lib.base(base)) throw new Error(`there is no knowledge base "${base}" — create it first`)
const out: Added[] = []
const put = (source: string, title: string, r: { text?: string; error?: string }) => {
const { text, truncated } = cap(r.text ?? "")
const { id, changed } = lib.putDocument(base, { title, source, text, truncated, error: r.error })
const a: Added = { source, id, changed, chars: text.length, truncated, error: r.error }
out.push(a)
o.progress?.(a)
}
for (const input of inputs) {
if (o.signal?.aborted) break
if (/^https?:\/\//i.test(input)) {
try {
// The user names it on the command line: a local address is theirs to add.
const page = await fetchPage(input, o.signal ?? new AbortController().signal, { allowPrivate: true })
if (page.status >= 400) put(input, input, { error: `HTTP ${page.status}` })
else put(input, page.title || input, { text: page.text })
} catch (e) {
put(input, input, { error: (e as Error).message })
}
continue
}
const path = resolve(o.cwd ?? process.cwd(), input.replace(/^~(?=\/|$)/, process.env.HOME ?? "~"))
if (!existsSync(path)) {
out.push({ source: path, error: "no such file or directory" })
o.progress?.(out.at(-1)!)
continue
}
if (statSync(path).isDirectory()) {
const root = realpathSync(path)
for (const f of filesUnder(path)) {
if (o.signal?.aborted) break
// A link that leads out of the directory is not part of it.
let real: string
try {
if (!lstatSync(f).isFile() && !lstatSync(f).isSymbolicLink()) continue
real = realpathSync(f)
if (!statSync(real).isFile()) continue
} catch {
continue
}
if (real !== root && !real.startsWith(`${root}/`)) continue
const r = readText(real)
if (r.error === "not a text file") continue
put(real, relative(path, f), r)
}
continue
}
put(realpathSync(path), basename(path), readText(path))
}
return out
}
+300
View File
@@ -0,0 +1,300 @@
// The library, after LLeMbas's (services/library/, © Jaroslav Beneš, MIT):
// notes the agent keeps, and knowledge bases of documents — files, directories, web pages —
// split into pieces and searched by their words (FTS5, bm25) and, when an embedding model is
// set, by meaning too, the two fused by reciprocal rank. One SQLite file,
// ~/.local/share/lembas/library.db, shared by every project; a project may narrow which
// bases it searches (`knowledge:` in its config).
import { Database } from "bun:sqlite"
import { createHash } from "node:crypto"
import { mkdirSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
import { anyOf, ftsQuery } from "../tool/session_search.ts"
import { split } from "./chunks.ts"
const SCHEMA = `
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL,
created INTEGER NOT NULL, updated INTEGER NOT NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(title, body, content='notes', content_rowid='id');
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts (rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts (notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts (notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
INSERT INTO notes_fts (rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TABLE IF NOT EXISTS bases (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT, base_id INTEGER NOT NULL REFERENCES bases(id) ON DELETE CASCADE,
title TEXT NOT NULL, source TEXT NOT NULL, text TEXT NOT NULL, hash TEXT NOT NULL,
truncated INTEGER NOT NULL DEFAULT 0, error TEXT, added INTEGER NOT NULL,
UNIQUE (base_id, source)
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT, doc_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
ordinal INTEGER NOT NULL, text TEXT NOT NULL, vector BLOB, dims INTEGER, model TEXT
);
CREATE INDEX IF NOT EXISTS chunks_doc ON chunks(doc_id, ordinal);
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, content='chunks', content_rowid='id');
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
INSERT INTO chunks_fts (rowid, text) VALUES (new.id, new.text);
END;
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
INSERT INTO chunks_fts (chunks_fts, rowid, text) VALUES ('delete', old.id, old.text);
END;
`
/** Reciprocal-rank fusion: a result's score is the sum over the lists it is in of 1/(K + rank). */
export const RRF_K = 60
/** Pieces looked at per document wanted: a document scores as its best piece. */
const CHUNK_MULTIPLIER = 6
export const SNIPPET_CHARS = 1200
export interface Base {
id: number
name: string
description: string
documents: number
}
export interface Doc {
id: number
base: string
title: string
source: string
chars: number
truncated: boolean
error?: string
added: number
}
export interface Hit {
id: number
base: string
title: string
source: string
snippet: string
}
export interface Note {
id: number
scope: string
title: string
body: string
updated: number
}
/** An embedding model: turns texts into vectors (unit length), all of one width. */
export interface Embedder {
model: string
embed(texts: string[], signal?: AbortSignal): Promise<Float32Array[]>
}
const hash = (s: string) => createHash("sha256").update(s).digest("hex")
const toBlob = (v: Float32Array) => new Uint8Array(v.buffer, v.byteOffset, v.byteLength)
const fromBlob = (b: Uint8Array) => new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength))
export class Library {
readonly db: Database
constructor(file = join(paths.data, "library.db")) {
mkdirSync(paths.data, { recursive: true })
this.db = new Database(file, { create: true })
this.db.run("PRAGMA journal_mode = WAL")
this.db.run("PRAGMA foreign_keys = ON")
this.db.exec(SCHEMA)
}
close() {
this.db.close()
}
// ── notes ────────────────────────────────────────────────────────────────────────────────
addNote(scope: string, title: string, body: string): Note {
const now = Date.now()
const r = this.db.query("INSERT INTO notes (scope, title, body, created, updated) VALUES (?, ?, ?, ?, ?)").run(scope, title, body, now, now)
return { id: Number(r.lastInsertRowid), scope, title, body, updated: now }
}
note(id: number): Note | undefined {
return (this.db.query("SELECT id, scope, title, body, updated FROM notes WHERE id = ?").get(id) as Note | null) ?? undefined
}
editNote(id: number, change: { title?: string; body?: string }) {
const n = this.note(id)
if (!n) throw new Error(`there is no note ${id}`)
this.db.query("UPDATE notes SET title = ?, body = ?, updated = ? WHERE id = ?").run(change.title ?? n.title, change.body ?? n.body, Date.now(), id)
}
deleteNote(id: number) {
this.db.query("DELETE FROM notes WHERE id = ?").run(id)
}
/** Notes in these scopes ("global", and a project's root), by words — or the newest, with no query. */
notes(scopes: string[], query?: string, limit = 10): Note[] {
const inScope = `n.scope IN (${scopes.map(() => "?").join(",")})`
if (!query?.trim()) return this.db.query(`SELECT id, scope, title, body, updated FROM notes n WHERE ${inScope} ORDER BY updated DESC LIMIT ?`).all(...scopes, limit) as Note[]
const run = (match: string) =>
this.db.query(`SELECT n.id, n.scope, n.title, n.body, n.updated FROM notes_fts f JOIN notes n ON n.id = f.rowid WHERE notes_fts MATCH ? AND ${inScope} ORDER BY bm25(notes_fts) LIMIT ?`).all(match, ...scopes, limit) as Note[]
const q = ftsQuery(query)
if (!q) return []
const all = run(q)
const any = all.length ? undefined : anyOf(q)
return any ? run(any) : all
}
// ── knowledge bases ──────────────────────────────────────────────────────────────────────
createBase(name: string, description = ""): Base {
if (!/^[\w.-]{1,64}$/.test(name)) throw new Error(`"${name}" is not a base name: letters, digits, - _ . — up to 64`)
if (this.base(name)) throw new Error(`there is a knowledge base "${name}" already`)
const r = this.db.query("INSERT INTO bases (name, description, created) VALUES (?, ?, ?)").run(name, description, Date.now())
return { id: Number(r.lastInsertRowid), name, description, documents: 0 }
}
base(name: string): Base | undefined {
return (this.db.query("SELECT b.id, b.name, b.description, (SELECT count(*) FROM documents d WHERE d.base_id = b.id) AS documents FROM bases b WHERE b.name = ?").get(name) as Base | null) ?? undefined
}
bases(): Base[] {
return this.db.query("SELECT b.id, b.name, b.description, (SELECT count(*) FROM documents d WHERE d.base_id = b.id) AS documents FROM bases b ORDER BY b.name").all() as Base[]
}
deleteBase(name: string) {
this.db.query("DELETE FROM bases WHERE name = ?").run(name)
}
documents(base: string): Doc[] {
return (
this.db
.query("SELECT d.id, b.name AS base, d.title, d.source, length(d.text) AS chars, d.truncated, d.error, d.added FROM documents d JOIN bases b ON b.id = d.base_id WHERE b.name = ? ORDER BY d.title")
.all(base) as (Omit<Doc, "truncated" | "error"> & { truncated: number; error: string | null })[]
).map((d) => ({ ...d, truncated: !!d.truncated, error: d.error ?? undefined }))
}
document(id: number): (Doc & { text: string }) | undefined {
const d = this.db
.query("SELECT d.id, b.name AS base, d.title, d.source, d.text, length(d.text) AS chars, d.truncated, d.error, d.added FROM documents d JOIN bases b ON b.id = d.base_id WHERE d.id = ?")
.get(id) as (Omit<Doc, "truncated" | "error"> & { text: string; truncated: number; error: string | null }) | null
return d ? { ...d, truncated: !!d.truncated, error: d.error ?? undefined } : undefined
}
deleteDocument(id: number) {
this.db.query("DELETE FROM documents WHERE id = ?").run(id)
}
/** Put a document into a base, or replace the one from the same source. Unchanged text is left as
* it is (its pieces and vectors kept). Returns the document's id and whether anything changed. */
putDocument(base: string, d: { title: string; source: string; text: string; truncated?: boolean; error?: string }): { id: number; changed: boolean } {
const b = this.base(base)
if (!b) throw new Error(`there is no knowledge base "${base}"`)
const h = hash(d.text)
const old = this.db.query("SELECT id, hash FROM documents WHERE base_id = ? AND source = ?").get(b.id, d.source) as { id: number; hash: string } | null
if (old && old.hash === h) return { id: old.id, changed: false }
return this.db.transaction(() => {
if (old) this.db.query("DELETE FROM documents WHERE id = ?").run(old.id)
const r = this.db
.query("INSERT INTO documents (base_id, title, source, text, hash, truncated, error, added) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.run(b.id, d.title, d.source, d.text, h, d.truncated ? 1 : 0, d.error ?? null, Date.now())
const id = Number(r.lastInsertRowid)
const insert = this.db.query("INSERT INTO chunks (doc_id, ordinal, text) VALUES (?, ?, ?)")
split(d.text).forEach((t, i) => insert.run(id, i, t))
return { id, changed: true }
})()
}
/** Pieces with no vector from this model (new ones, or all of them after the model changed). */
unembedded(model: string, limit = 256): { id: number; text: string }[] {
return this.db.query("SELECT id, text FROM chunks WHERE vector IS NULL OR model IS NOT ? LIMIT ?").all(model, limit) as { id: number; text: string }[]
}
setVectors(model: string, rows: { id: number; vector: Float32Array }[]) {
const q = this.db.query("UPDATE chunks SET vector = ?, dims = ?, model = ? WHERE id = ?")
this.db.transaction(() => {
for (const r of rows) q.run(toBlob(r.vector), r.vector.length, model, r.id)
})()
}
/** Give every piece a vector from `e`, in batches; returns how many were embedded. */
async embedAll(e: Embedder, signal?: AbortSignal, batch = 16): Promise<number> {
let n = 0
for (;;) {
const rows = this.unembedded(e.model, batch)
if (!rows.length) return n
const vectors = await e.embed(rows.map((r) => r.text), signal)
if (vectors.length !== rows.length) throw new Error(`the embedding model returned ${vectors.length} vectors for ${rows.length} texts`)
this.setVectors(
e.model,
rows.map((r, i) => ({ id: r.id, vector: vectors[i]! })),
)
n += rows.length
}
}
/** Documents for a query: words (bm25) and, with an embedder, meaning (cosine) — fused by RRF.
* `bases`: only these (undefined: every base). */
async search(query: string, o: { bases?: string[]; limit?: number; embedder?: Embedder; signal?: AbortSignal } = {}): Promise<Hit[]> {
const limit = o.limit ?? 6
const want = limit * CHUNK_MULTIPLIER
const inBases = o.bases ? `AND b.name IN (${o.bases.map(() => "?").join(",") || "''"})` : ""
const bargs = o.bases ?? []
const lists: number[][] = []
const q = ftsQuery(query)
if (q) {
const run = (match: string) =>
(
this.db
.query(`SELECT c.id FROM chunks_fts f JOIN chunks c ON c.id = f.rowid JOIN documents d ON d.id = c.doc_id JOIN bases b ON b.id = d.base_id WHERE chunks_fts MATCH ? ${inBases} ORDER BY bm25(chunks_fts) LIMIT ?`)
.all(match, ...bargs, want) as { id: number }[]
).map((r) => r.id)
let ids = run(q)
const any = ids.length ? undefined : anyOf(q)
if (any) ids = run(any)
lists.push(ids)
}
if (o.embedder) {
const [v] = await o.embedder.embed([query], o.signal)
if (v) {
const rows = this.db
.query(`SELECT c.id, c.vector FROM chunks c JOIN documents d ON d.id = c.doc_id JOIN bases b ON b.id = d.base_id WHERE c.model = ? AND c.dims = ? ${inBases}`)
.all(o.embedder.model, v.length, ...bargs) as { id: number; vector: Uint8Array }[]
const scored = rows.map((r) => {
const w = fromBlob(r.vector)
let dot = 0
for (let i = 0; i < w.length; i++) dot += w[i]! * v[i]!
return { id: r.id, dot }
})
scored.sort((a, b) => b.dot - a.dot)
lists.push(scored.slice(0, want).map((s) => s.id))
}
}
// Fused per piece, then a document scores as its best piece.
const score = new Map<number, number>()
for (const list of lists) list.forEach((id, rank) => score.set(id, (score.get(id) ?? 0) + 1 / (RRF_K + rank + 1)))
const best = new Map<number, { chunk: number; score: number }>()
const docOf = this.db.query("SELECT doc_id FROM chunks WHERE id = ?")
for (const [chunk, s] of score) {
const doc = (docOf.get(chunk) as { doc_id: number }).doc_id
const cur = best.get(doc)
if (!cur || s > cur.score) best.set(doc, { chunk, score: s })
}
const top = [...best.entries()].sort((a, b) => b[1].score - a[1].score).slice(0, limit)
const meta = this.db.query("SELECT d.id, b.name AS base, d.title, d.source FROM documents d JOIN bases b ON b.id = d.base_id WHERE d.id = ?")
const text = this.db.query("SELECT text FROM chunks WHERE id = ?")
return top.map(([doc, { chunk }]) => {
const m = meta.get(doc) as { id: number; base: string; title: string; source: string }
const t = (text.get(chunk) as { text: string }).text
return { ...m, snippet: t.length > SNIPPET_CHARS ? `${t.slice(0, SNIPPET_CHARS)}…` : t }
})
}
}
+545
View File
@@ -0,0 +1,545 @@
// MCP servers (Model Context Protocol), after Hermes Agent (tools/mcp_tool*.py; MIT, © Nous
// Research) and OpenCode (packages/opencode/src/mcp/; MIT, © opencode), on the official SDK.
//
// - A server is a local process (`command`, stdio) or a URL (streamable HTTP, SSE as fallback).
// - Its tools become LLeMbas CLI tools named mcp__<server>__<tool>; each call is checked by the
// permission rules like any tool (key = that name), and asks by default.
// - Its prompts become /<server>:<prompt> commands; its resources get list/read tools; its
// instructions go into the system prompt (OpenCode's <mcp_instructions>).
// - Connecting happens in the background; tools appear on the next prompt after a server is up.
import { createHash } from "node:crypto"
import { isAbsolute, join } from "node:path"
import { z } from "zod"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"
import { ToolListChangedNotificationSchema, PromptListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
import type { McpServer } from "../config/schema.ts"
import { expandHome } from "../config/paths.ts"
import { scanThreats } from "../memory/threats.ts"
import { tlsFor } from "../provider/http.ts"
import type { ImagePart } from "../provider/types.ts"
import { ToolError, truncate, type Tool, type ToolResult } from "../tool/tool.ts"
import { match } from "../permission/wildcard.ts"
import { codeFrom, forget, OAuthProvider, waitForCallback, CALLBACK_PORT } from "./oauth.ts"
export type ServerConfig = McpServer & { source: "global" | "project" }
export type Status = "disabled" | "connecting" | "connected" | "failed" | "needs_auth"
interface RemoteTool {
name: string
description?: string
inputSchema: Record<string, unknown>
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean; title?: string }
}
interface RemotePrompt {
name: string
description?: string
arguments?: { name: string; description?: string; required?: boolean }[]
}
export interface ServerState {
name: string
cfg: ServerConfig
status: Status
error?: string
client?: Client
transport?: Transport
tools: RemoteTool[]
prompts: RemotePrompt[]
resources: boolean
instructions?: string
/** The last lines the process wrote to stderr (stdio), for a failure's explanation. */
stderr: string[]
oauth?: OAuthProvider
/** The connect under way, which every other caller waits on rather than starting one. */
connecting?: Promise<ServerState>
/** Bumped by every connect and disable: a connect that finds it changed gives up quietly. */
gen: number
loading?: Promise<void>
reloadAgain?: boolean
}
export interface McpPromptCommand {
/** `server:prompt`, as typed after the slash. */
name: string
description: string
args: string[]
server: string
prompt: string
}
const sanitize = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, "_")
/** The LLeMbas library server (`library: lembas`): its tools keep their own names, as in a
* LLeMbas chat, and stand in for this machine's memory, notes, skills and knowledge tools. */
export const LIBRARY_SERVER = "lembas-library"
/** mcp__<server>__<tool>, at most 64 characters (the providers' limit); longer ones keep a hash. */
export function toolName(server: string, tool: string): string {
const full = `mcp__${sanitize(server)}__${sanitize(tool)}`
if (full.length <= 64) return full
return `${full.slice(0, 55)}_${createHash("sha256").update(full).digest("hex").slice(0, 8)}`
}
/** include wins; otherwise exclude; names or globs. */
export function toolAllowed(name: string, filter: McpServer["tools"]): boolean {
if (filter?.include) return filter.include.some((p) => match(name, p))
if (filter?.exclude) return !filter.exclude.some((p) => match(name, p))
return true
}
/** A tool result from MCP content blocks: text as text, images for the model, the rest named. */
export function convertResult(server: string, r: any): ToolResult {
const texts: string[] = []
const images: ImagePart[] = []
for (const b of (r?.content ?? []) as any[]) {
if (b.type === "text") texts.push(String(b.text ?? ""))
else if (b.type === "image" && typeof b.data === "string") {
images.push({ type: "image", mime: b.mimeType ?? "image/png", data: b.data })
texts.push(`[image: ${b.mimeType ?? "image"}, attached]`)
} else if (b.type === "audio") texts.push(`[audio (${b.mimeType ?? "?"}) — not passed on]`)
else if (b.type === "resource") {
const res = b.resource ?? {}
if (typeof res.text === "string") texts.push(`[resource ${res.uri ?? ""}]\n${res.text}`)
else texts.push(`[binary resource ${res.uri ?? ""} (${res.mimeType ?? "?"}, ${typeof res.blob === "string" ? Math.floor((res.blob.length * 3) / 4) : "?"} bytes) — not passed on]`)
} else if (b.type === "resource_link") texts.push(`[resource link: ${b.uri}${b.name ? ` (${b.name})` : ""}${b.mimeType ? `, ${b.mimeType}` : ""} — read it with ${toolName(server, "read_resource")}]`)
else texts.push(`[unsupported MCP content: ${b.type}]`)
}
let text = texts.join("\n\n").trim()
if (r?.structuredContent !== undefined && (!text || text === JSON.stringify(r.structuredContent))) text = JSON.stringify(r.structuredContent, null, 2)
if (!text && r?.toolResult !== undefined) text = typeof r.toolResult === "string" ? r.toolResult : JSON.stringify(r.toolResult)
if (r?.isError) return { output: truncate(text || "The MCP tool reported an error."), isError: true, title: "error" }
return { output: truncate(text || "(no output)"), ...(images.length ? { images } : {}) }
}
/** The parameters schema as providers want it: an object, with properties. */
function objectSchema(s: unknown): Record<string, unknown> {
const o = s && typeof s === "object" && !Array.isArray(s) ? { ...(s as Record<string, unknown>) } : {}
delete o.$schema
if (o.type !== "object") o.type = "object"
if (!o.properties) o.properties = {}
return o
}
const MAX_INSTRUCTIONS = 4000
const LOOSE = z.looseObject({})
export interface McpOptions {
/** The project root: where a local server runs unless it says otherwise. */
root: string
version: string
notice?: (message: string) => void
/** Open a URL in the user's browser; false when that is not possible. */
open?: (url: string) => boolean
}
/** Local server processes still running, killed when LLeMbas CLI exits however it exits. */
const live = new Set<StdioClientTransport>()
let exitHook = false
export class McpManager {
readonly servers = new Map<string, ServerState>()
private listeners: (() => void)[] = []
/** Called whenever a server's status, tools or prompts change. */
onChange(f: () => void) {
this.listeners.push(f)
}
private changed() {
for (const f of this.listeners) f()
}
constructor(
configs: Record<string, ServerConfig>,
private o: McpOptions,
) {
if (!exitHook) {
exitHook = true
process.once("exit", () => {
for (const t of live)
try {
if (t.pid) process.kill(t.pid)
} catch {}
})
}
for (const [name, cfg] of Object.entries(configs))
this.servers.set(name, { name, cfg, status: cfg.enabled === false ? "disabled" : "connecting", tools: [], prompts: [], resources: false, stderr: [], gen: 0 })
}
/** Connect every enabled server, together; resolves when all have succeeded or failed. */
start(): Promise<void> {
return Promise.all([...this.servers.values()].filter((s) => s.status !== "disabled").map((s) => this.connect(s.name))).then(() => undefined)
}
private transportFor(s: ServerState, kind: "http" | "sse"): Transport {
const cfg = s.cfg
if (cfg.command !== undefined) {
const [command, ...rest] = Array.isArray(cfg.command) ? cfg.command : [cfg.command]
// The server gets a safe minimum of the environment (as Hermes does), plus what its config
// names — not every API key in the shell.
const env: Record<string, string> = { ...getDefaultEnvironment() }
for (const [k, v] of Object.entries(process.env)) if (v !== undefined && (/^(XDG_|LC_)/.test(k) || ["LANG", "TZ", "TMPDIR", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE"].includes(k))) env[k] = v
Object.assign(env, cfg.env ?? {})
const cwd = cfg.cwd ? (isAbsolute(expandHome(cfg.cwd)) ? expandHome(cfg.cwd) : join(this.o.root, cfg.cwd)) : this.o.root
const t = new StdioClientTransport({ command: command!, args: [...rest, ...(cfg.args ?? [])], env, cwd, stderr: "pipe" })
live.add(t)
const close = t.close.bind(t)
t.close = async () => {
live.delete(t)
await close()
}
t.stderr?.on("data", (chunk: Buffer) => {
s.stderr.push(...chunk.toString().split("\n").filter(Boolean))
if (s.stderr.length > 20) s.stderr.splice(0, s.stderr.length - 20)
})
return t
}
const url = new URL(cfg.url!)
const tls = tlsFor(cfg)
const fetchWith = tls ? (u: string | URL, init?: RequestInit) => fetch(u, { ...init, tls } as RequestInit) : undefined
if (cfg.oauth !== false) s.oauth ??= new OAuthProvider(s.name, cfg.url!, cfg.oauth ?? {})
const opts = { requestInit: { headers: cfg.headers ?? {} }, ...(s.oauth ? { authProvider: s.oauth } : {}), ...(fetchWith ? { fetch: fetchWith } : {}) }
return kind === "sse" ? new SSEClientTransport(url, opts) : new StreamableHTTPClientTransport(url, opts)
}
/** (Re)connect one server. Never throws: the outcome is its status. Calls while one is under way
* (three tool calls finding it dropped) share it, rather than starting a process each. */
connect(name: string): Promise<ServerState> {
const s = this.servers.get(name)
if (!s) throw new Error(`no MCP server "${name}"`)
if (s.connecting) return s.connecting
const gen = ++s.gen
s.connecting = this.connectNow(s, gen).finally(() => {
if (s.gen === gen) s.connecting = undefined
})
return s.connecting
}
private async connectNow(s: ServerState, gen: number): Promise<ServerState> {
await this.disconnect(s)
s.status = "connecting"
s.error = undefined
this.changed()
const kinds: ("http" | "sse")[] = s.cfg.command !== undefined ? ["http"] : s.cfg.transport === "sse" ? ["sse"] : ["http", "sse"]
for (const [i, kind] of kinds.entries()) {
const client = new Client({ name: "lembas-cli", version: this.o.version }, { capabilities: {} })
let transport: Transport
try {
transport = this.transportFor(s, kind)
} catch (e) {
return this.fail(s, (e as Error).message)
}
const limit = (s.cfg.connect_timeout ?? 30) * 1000
try {
await withTimeout(client.connect(transport), limit, `no answer within ${s.cfg.connect_timeout ?? 30} s`)
// Disabled or reconnected meanwhile: this connection is nobody's.
if (s.gen !== gen) {
await client.close().catch(() => {})
return s
}
s.client = client
s.transport = transport
client.onclose = () => {
if (s.client !== client) return
s.client = undefined
s.status = "failed"
s.error = `the connection closed${s.stderr.length ? ` — ${s.stderr[s.stderr.length - 1]}` : ""}`
this.changed()
}
// Set before the first listing, so a change announced during it is not missed.
const relist = () => void this.reload(s).then(() => this.changed(), () => {})
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => relist())
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => relist())
// The listing is part of connecting: a server that never finishes it must not hang startup.
await withTimeout(this.reload(s), limit, `its tools were not listed within ${s.cfg.connect_timeout ?? 30} s`)
if (s.gen !== gen) return s
s.status = "connected"
this.changed()
return s
} catch (e) {
if (s.client === client) {
s.client = undefined
s.transport = undefined
}
await client.close().catch(() => {})
await transport.close().catch(() => {})
if (s.gen !== gen) return s
if (e instanceof UnauthorizedError || s.oauth?.authorizationUrl) {
s.status = "needs_auth"
s.error = `sign-in needed — /mcp auth ${s.name}`
this.changed()
return s
}
// Streamable HTTP refused: try SSE once, as Hermes and OpenCode do.
if (i < kinds.length - 1) continue
const why = (e as Error).message || String(e)
return this.fail(s, s.stderr.length ? `${why} — ${s.stderr.slice(-3).join(" | ")}` : why)
}
}
return s
}
private fail(s: ServerState, why: string): ServerState {
s.status = "failed"
s.error = why
this.changed()
return s
}
/** One listing at a time per server; a change announced during one runs another after it. */
private reload(s: ServerState): Promise<void> {
if (s.loading) {
s.reloadAgain = true
return s.loading
}
s.loading = (async () => {
do {
s.reloadAgain = false
await this.load(s)
} while (s.reloadAgain)
})().finally(() => (s.loading = undefined))
return s.loading
}
/** Tools, prompts, resources and instructions, as the server has them now. */
private async load(s: ServerState) {
const c = s.client
if (!c) return
const caps = c.getServerCapabilities() ?? {}
// Pages until the server stops giving a cursor — but not forever: a cursor seen before, or a
// hundred pages, ends it.
const pages = async <T>(get: (cursor?: string) => Promise<{ items: T[]; next?: string }>) => {
const out: T[] = []
const seen = new Set<string>()
let cursor: string | undefined
for (let n = 0; n < 100; n++) {
const r = await get(cursor)
out.push(...r.items)
if (!r.next || seen.has(r.next)) break
seen.add(r.next)
cursor = r.next
}
return out
}
const tools = caps.tools ? await pages(async (cursor) => {
const r = await c.listTools(cursor ? { cursor } : undefined)
return { items: r.tools as RemoteTool[], next: r.nextCursor }
}) : []
const prompts = caps.prompts && s.cfg.prompts !== false ? await pages(async (cursor) => {
const r = await c.listPrompts(cursor ? { cursor } : undefined)
return { items: r.prompts as RemotePrompt[], next: r.nextCursor }
}) : []
s.tools = tools.filter((t) => toolAllowed(t.name, s.cfg.tools))
s.prompts = prompts
s.resources = !!caps.resources && s.cfg.resources !== false
const ins = s.cfg.instructions === false ? undefined : c.getInstructions()?.trim()
s.instructions = undefined
if (ins) {
// The server wrote this, not the user: anything aimed at the agent is left out.
const threats = scanThreats(ins, "context")
if (threats.length) this.o.notice?.(`MCP ${s.name}: its instructions match ${threats.join(", ")} — left out of the prompt`)
else s.instructions = ins.length > MAX_INSTRUCTIONS ? `${ins.slice(0, MAX_INSTRUCTIONS)}\n[…cut]` : ins
}
}
private async disconnect(s: ServerState) {
const c = s.client
s.client = undefined
s.transport = undefined
if (c) await c.close().catch(() => {})
}
/** A connected client, reconnecting once if the connection dropped (Hermes retries once). */
private async clientOf(name: string): Promise<Client> {
let s = this.servers.get(name)!
if (!s.client && s.status !== "disabled" && s.status !== "needs_auth") s = await this.connect(name)
if (!s.client) throw new ToolError(`MCP server ${name} is not connected: ${s.error ?? s.status}. /mcp shows the servers.`)
return s.client
}
/** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
tools(): Tool[] {
const out: Tool[] = []
// Different names can come out the same (get.user and get_user; server a.b and a_b): the
// second gets a hash of what it really is, so every tool stays reachable and names stay unique.
const used = new Set<string>()
const unique = (server: string, tool: string) => {
let n = toolName(server, tool)
if (used.has(n)) n = `${n.slice(0, 55)}_${createHash("sha256").update(`${server}\u0000${tool}`).digest("hex").slice(0, 8)}`
used.add(n)
return n
}
for (const s of this.servers.values()) {
if (s.status !== "connected") continue
for (const t of s.tools) {
const library = s.name === LIBRARY_SERVER
const name = library ? sanitize(t.name) : unique(s.name, t.name)
if (library) used.add(name)
const readOnly = t.annotations?.readOnlyHint === true
out.push({
name,
access: name,
description: library ? (t.description ?? t.name).trim() : `${(t.description ?? `${t.name} from the MCP server ${s.name}`).trim()}\n(MCP server: ${s.name})`,
schema: LOOSE,
jsonSchema: objectSchema(t.inputSchema),
// The library's writes are the account's memory and notes, not this machine: asked like
// the local memory tool (interact), never run unasked as a command would be denied.
permission: () => ({ permission: name, class: readOnly ? "read" : library ? "interact" : "execute", patterns: ["*"] }),
run: async (args, ctx) => {
const c = await this.clientOf(s.name)
try {
const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown> }, undefined, {
signal: ctx.signal,
timeout: (s.cfg.timeout ?? 120) * 1000,
resetTimeoutOnProgress: true,
onprogress: () => {},
})
const res = convertResult(s.name, r)
const first = res.output.split("\n").find((l) => l.trim()) ?? ""
return { ...res, title: res.title ?? (first.length > 60 ? `${first.slice(0, 57)}…` : first) }
} catch (e) {
throw new ToolError(`${s.name}/${t.name}: ${(e as Error).message}`)
}
},
} as Tool)
}
if (s.resources) {
const list = unique(s.name, "list_resources")
const read = unique(s.name, "read_resource")
out.push(
{
name: list,
access: list,
description: `List the resources the MCP server ${s.name} offers (documents, files, records it can hand over).`,
schema: LOOSE,
jsonSchema: { type: "object", properties: {} },
permission: () => ({ permission: list, class: "read", patterns: ["*"] }),
run: async () => {
const c = await this.clientOf(s.name)
const r = await c.listResources()
const rows = r.resources.map((x: any) => `${x.uri}${x.name ? ` — ${x.name}` : ""}${x.mimeType ? ` (${x.mimeType})` : ""}${x.description ? `: ${x.description}` : ""}`)
return { output: rows.join("\n") || "(no resources)", title: `${s.name} · ${rows.length} resources` }
},
} as Tool,
{
name: read,
access: read,
description: `Read one resource from the MCP server ${s.name}, by its URI (from ${list}, or a resource link in a tool result).`,
schema: z.object({ uri: z.string() }),
jsonSchema: { type: "object", properties: { uri: { type: "string", description: "The resource's URI" } }, required: ["uri"] },
permission: (a: any) => ({ permission: read, class: "read", patterns: [String(a.uri)] }),
run: async (a: any) => {
const c = await this.clientOf(s.name)
const r = await c.readResource({ uri: a.uri })
return { ...convertResult(s.name, { content: r.contents.map((x: any) => ({ type: "resource", resource: x })) }), title: `${s.name} · ${a.uri}` }
},
} as Tool,
)
}
}
return out
}
/** Prompts as /server:prompt commands. */
prompts(): McpPromptCommand[] {
return [...this.servers.values()]
.filter((s) => s.status === "connected")
.flatMap((s) => s.prompts.map((p) => ({ name: `${sanitize(s.name)}:${sanitize(p.name)}`, description: p.description ?? `prompt from ${s.name}`, args: (p.arguments ?? []).map((a) => a.name), server: s.name, prompt: p.name })))
}
/** A prompt's text with the typed words as its arguments, in order (the last takes the rest). */
async promptText(cmd: McpPromptCommand, typed: string): Promise<string> {
const words = typed.trim() ? typed.trim().split(/\s+/) : []
const args: Record<string, string> = {}
cmd.args.forEach((a, i) => {
const v = i === cmd.args.length - 1 ? words.slice(i).join(" ") : words[i]
if (v) args[a] = v
})
const c = await this.clientOf(cmd.server)
const r = await c.getPrompt({ name: cmd.prompt, arguments: args })
return r.messages
.map((m: any) => (m.content?.type === "text" ? m.content.text : m.content?.type === "resource" && typeof m.content.resource?.text === "string" ? m.content.resource.text : ""))
.filter(Boolean)
.join("\n\n")
}
/** The system prompt's block of server instructions, or "". */
instructions(): string {
const parts = [...this.servers.values()].filter((s) => s.status === "connected" && s.instructions).map((s) => `<server name="${s.name}">\n${s.instructions}\n</server>`)
return parts.length ? `Instructions from the MCP servers you have tools from (their tools are named mcp__<server>__…):\n<mcp_instructions>\n${parts.join("\n")}\n</mcp_instructions>` : ""
}
/** Sign in to a remote server: show the page, catch the redirect (or take a pasted one). */
async auth(name: string, show: (url: string) => void, pasted?: Promise<string>): Promise<ServerState> {
const s = this.servers.get(name)
if (!s) throw new Error(`no MCP server "${name}"`)
if (s.cfg.url === undefined) throw new Error(`${name} is a local server; it has no sign-in`)
if (s.cfg.oauth === false) throw new Error(`${name} has oauth: false`)
// A connect already under way uses the old provider; let it finish first.
await s.connecting?.catch(() => {})
const oauth = new OAuthProvider(s.name, s.cfg.url, s.cfg.oauth ?? {})
s.oauth = oauth
const port = (s.cfg.oauth || undefined)?.callback_port ?? CALLBACK_PORT
const cb = waitForCallback(port, () => oauth.expectedState())
try {
if (cb.error && !pasted) throw cb.error
if (cb.error) this.o.notice?.(cb.error.message)
await this.connect(name)
if (s.status === "connected") return s
const url = oauth.authorizationUrl
if (!url) throw new Error(s.error ?? "the server did not ask for a sign-in")
show(url.toString())
this.o.open?.(url.toString())
const got = await Promise.race([
// The listener has checked the state itself.
...(cb.error ? [] : [cb.code.then((code) => ({ code, state: undefined as string | undefined, fromUrl: false }))]),
...(pasted ? [pasted.then(codeFrom)] : []),
])
const want = oauth.expectedState()
// A pasted address must carry this sign-in's state; a bare code (some servers show one) is taken.
if (want && (got.state !== undefined || got.fromUrl) && got.state !== want) throw new Error("OAuth state mismatch — that address is from another sign-in")
const t = this.transportFor(s, s.cfg.transport === "sse" ? "sse" : "http") as StreamableHTTPClientTransport | SSEClientTransport
await t.finishAuth(got.code)
await t.close().catch(() => {})
oauth.authorizationUrl = undefined
return await this.connect(name)
} finally {
cb.stop()
}
}
/** Forget a server's stored sign-in. */
logout(name: string) {
forget(name)
const s = this.servers.get(name)
if (s) s.oauth = undefined
}
async setEnabled(name: string, on: boolean) {
const s = this.servers.get(name)
if (!s) throw new Error(`no MCP server "${name}"`)
if (!on) {
// A connect under way sees the changed generation and gives up.
s.gen++
s.connecting = undefined
await this.disconnect(s)
s.status = "disabled"
this.changed()
return s
}
return this.connect(name)
}
async close() {
await Promise.all([...this.servers.values()].map((s) => this.disconnect(s)))
}
}
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
let t: ReturnType<typeof setTimeout>
return Promise.race([p, new Promise<T>((_, reject) => (t = setTimeout(() => reject(new Error(message)), ms)))]).finally(() => clearTimeout(t))
}
+221
View File
@@ -0,0 +1,221 @@
// OAuth for remote MCP servers, after OpenCode (packages/opencode/src/mcp/oauth-provider.ts,
// oauth-callback.ts, auth.ts; MIT, © opencode): the SDK does discovery, dynamic registration,
// PKCE and refresh; this keeps what it hands over (client, tokens) in
// ~/.local/share/lembas/mcp-auth.json (mode 600), keyed by server and checked against its URL,
// and catches the redirect on 127.0.0.1. A sign-in's state and PKCE verifier stay in memory, with
// the sign-in: another LLeMbas CLI connecting meanwhile must not replace them.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs"
import { join } from "node:path"
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import { paths } from "../config/paths.ts"
import type { McpServer } from "../config/schema.ts"
export const CALLBACK_PORT = 19876
export const CALLBACK_PATH = "/mcp/oauth/callback"
interface Entry {
url: string
client?: OAuthClientInformationMixed
tokens?: OAuthTokens
}
const file = () => join(paths.data, "mcp-auth.json")
/** Every stored sign-in. `strict` (before a write): a file that does not parse is an error, not
* an empty one — writing over it would lose every sign-in in it. */
function readAll(strict = false): Record<string, Entry> {
if (!existsSync(file())) return {}
try {
return JSON.parse(readFileSync(file(), "utf8")) as Record<string, Entry>
} catch (e) {
if (strict) throw new Error(`${file()} is not valid JSON (${(e as Error).message}); fix or delete it`)
return {}
}
}
function writeAll(all: Record<string, Entry>) {
mkdirSync(paths.data, { recursive: true })
const tmp = `${file()}.${process.pid}.tmp`
writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600 })
chmodSync(tmp, 0o600)
renameSync(tmp, file())
}
// Stored by name and URL together: a project's server with the same name as one of yours, at
// another address, must not replace (or forget) your sign-in. Entries from before were stored by
// name alone, and are still read when their URL matches.
const keyOf = (name: string, url: string) => `${name} ${url}`
/** What is stored for a server, if it was stored for this URL (a changed URL starts over). */
export function authEntry(name: string, url: string): Entry | undefined {
const all = readAll()
const e = all[keyOf(name, url)] ?? all[name]
return e && e.url === url ? e : undefined
}
function update(name: string, url: string, change: (e: Entry) => void) {
const all = readAll(true)
const old = all[keyOf(name, url)] ?? (all[name]?.url === url ? all[name] : undefined)
const e: Entry = old ?? { url }
change(e)
if (all[name]?.url === url) delete all[name]
all[keyOf(name, url)] = e
writeAll(all)
}
/** Forget a server's sign-in: at this URL, or (no URL) every one stored under the name. */
export function forget(name: string, url?: string) {
const all = readAll(true)
for (const k of Object.keys(all)) if ((k === name && (url === undefined || all[k]!.url === url)) || (url === undefined ? k.startsWith(`${name} `) : k === keyOf(name, url))) delete all[k]
writeAll(all)
}
type OAuthConfig = Exclude<NonNullable<McpServer["oauth"]>, false>
export class OAuthProvider implements OAuthClientProvider {
/** Set when the SDK wants the user to authorise: the page to open. */
authorizationUrl?: URL
private stateValue?: string
private verifier?: string
constructor(
private name: string,
private url: string,
private cfg: OAuthConfig = {},
) {}
get redirectUrl() {
return `http://127.0.0.1:${this.cfg.callback_port ?? CALLBACK_PORT}${CALLBACK_PATH}`
}
get clientMetadata(): OAuthClientMetadata {
return {
client_name: "LLeMbas CLI",
redirect_uris: [this.redirectUrl],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: this.cfg.client_secret ? "client_secret_post" : "none",
...(this.cfg.scope ? { scope: this.cfg.scope } : {}),
}
}
state(): string {
this.stateValue = [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("")
return this.stateValue
}
expectedState(): string | undefined {
return this.stateValue
}
clientInformation(): OAuthClientInformationMixed | undefined {
if (this.cfg.client_id) return { client_id: this.cfg.client_id, ...(this.cfg.client_secret ? { client_secret: this.cfg.client_secret } : {}) }
return authEntry(this.name, this.url)?.client
}
saveClientInformation(info: OAuthClientInformationMixed) {
update(this.name, this.url, (e) => (e.client = info))
}
tokens(): OAuthTokens | undefined {
return authEntry(this.name, this.url)?.tokens
}
saveTokens(tokens: OAuthTokens) {
update(this.name, this.url, (e) => (e.tokens = tokens))
}
redirectToAuthorization(url: URL) {
this.authorizationUrl = url
}
saveCodeVerifier(v: string) {
this.verifier = v
}
codeVerifier(): string {
if (!this.verifier) throw new Error("no PKCE verifier — start the sign-in again")
return this.verifier
}
invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery") {
if (scope === "verifier" || scope === "all") this.verifier = undefined
if (scope === "verifier" || scope === "discovery") return
if (scope === "all") return forget(this.name, this.url)
update(this.name, this.url, (e) => {
if (scope === "client") delete e.client
if (scope === "tokens") delete e.tokens
})
}
}
/** The authorisation code from what the user has: the whole redirect URL, or the code itself. */
export function codeFrom(input: string): { code: string; state?: string; fromUrl?: boolean } {
const t = input.trim()
try {
const u = new URL(t)
const code = u.searchParams.get("code")
if (code) return { code, state: u.searchParams.get("state") ?? undefined, fromUrl: true }
} catch {}
return { code: t }
}
const esc = (t: string) => t.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
/** Wait for the browser's redirect on 127.0.0.1. `code` resolves with the code, or rejects on an
* error from the server or after `timeoutMs`. Only a request carrying this sign-in's state is
* acted on: any web page can reach 127.0.0.1, and must not be able to end the sign-in. `error`:
* the port could not be listened on (a pasted address is then the only way). */
export function waitForCallback(port: number, expectedState: () => string | undefined, timeoutMs = 5 * 60_000): { code: Promise<string>; stop(): void; error?: Error } {
let server: ReturnType<typeof Bun.serve> | undefined
let timer: ReturnType<typeof setTimeout> | undefined
const stop = () => {
clearTimeout(timer)
server?.stop(true)
server = undefined
}
const page = (title: string, text: string, status = 200) =>
new Response(`<!doctype html><meta charset="utf-8"><title>${esc(title)}</title><body style="font-family:sans-serif;padding:2em"><h2>${esc(title)}</h2><p>${esc(text)}</p>`, {
status,
headers: { "content-type": "text/html; charset=utf-8" },
})
let resolve!: (c: string) => void
let reject!: (e: Error) => void
const code = new Promise<string>((a, b) => ((resolve = a), (reject = b)))
// Whoever races it handles a rejection; one nobody is waiting for is not an error.
code.catch(() => {})
let error: Error | undefined
try {
server = Bun.serve({
port,
hostname: "127.0.0.1",
fetch(req) {
const u = new URL(req.url)
if (u.pathname !== CALLBACK_PATH) return new Response("not found", { status: 404 })
const want = expectedState()
if (!want || u.searchParams.get("state") !== want) return page("Not this sign-in", "This address does not belong to the sign-in LLeMbas CLI is waiting for.", 400)
const err = u.searchParams.get("error")
if (err) {
setTimeout(stop, 100)
const detail = u.searchParams.get("error_description")
reject(new Error(`the server refused: ${err}${detail ? ` — ${detail}` : ""}`))
return page("Sign-in failed", `The server said: ${err}. You can close this tab.`)
}
const c = u.searchParams.get("code")
if (!c) return page("No code", "This address carried no authorisation code.", 400)
setTimeout(stop, 100)
resolve(c)
return page("Signed in", "LLeMbas CLI has the authorisation. You can close this tab.")
},
})
timer = setTimeout(() => {
stop()
reject(new Error("no sign-in within 5 minutes"))
}, timeoutMs)
} catch (e) {
error = new Error(`cannot listen on 127.0.0.1:${port} for the sign-in redirect (${(e as Error).message}); paste the redirect address instead`)
reject(error)
}
return { code, stop, error }
}
+172
View File
@@ -0,0 +1,172 @@
// Persistent memory, after Hermes Agent (tools/memory_tool.py, tools/memory_tool_store.py, MIT,
// © Nous Research): two small files in ~/.config/lembas/memory/, MEMORY.md (the agent's notes)
// and USER.md (who the user is), entries separated by a line holding only "§". Both go into the
// system prompt as a snapshot taken when a session starts; writes during the session reach the
// file but not the prompt, so the prompt stays the same (and cacheable) all session.
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
import { threatMessage } from "./threats.ts"
export type Target = "memory" | "user"
export const DELIMITER = "\n§\n"
export const DEFAULT_LIMITS: Record<Target, number> = { memory: 2200, user: 1375 }
/** One fact per entry, and a short one (LLeMbas's MAX_MEMORY_CHARS): a long entry is several facts,
* or a note that belongs in a skill or the project's records. */
export const MAX_ENTRY = 400
const FILES: Record<Target, string> = { memory: "MEMORY.md", user: "USER.md" }
const HEADERS: Record<Target, string> = { memory: "MEMORY (your personal notes)", user: "USER PROFILE (who the user is)" }
export interface Op {
action: "add" | "replace" | "remove"
content?: string
old_text?: string
}
export interface Outcome {
ok: boolean
message: string
/** On a failure the model can fix by consolidating: what is there now. */
entries?: string[]
usage: string
}
export const memoryDir = () => join(paths.config, "memory")
export function parseEntries(raw: string): string[] {
const seen = new Set<string>()
return raw
.replace(/^/, "")
.replace(/\r\n/g, "\n")
.split(/\n[ \t]*§[ \t]*\n/)
.map((e) => e.trim())
.filter((e) => e && e !== "§" && !seen.has(e) && seen.add(e))
}
/** An entry `old` identifies: an exact match first, else the one entry containing it. */
export function findEntry(entries: string[], old: string): { index?: number; ambiguous?: boolean } {
const exact = entries.findIndex((e) => e === old)
if (exact >= 0) return { index: exact }
const hits = entries.map((e, i) => (e.includes(old) ? i : -1)).filter((i) => i >= 0)
if (hits.length > 1) return { ambiguous: true }
return hits.length ? { index: hits[0] } : {}
}
export class MemoryStore {
constructor(
private limits: Record<Target, number> = DEFAULT_LIMITS,
private dir = memoryDir(),
/** Headings in the system prompt, where they are not the global files' (a project's memory). */
private headers: Partial<Record<Target, string>> = {},
) {}
file(t: Target) {
return join(this.dir, FILES[t])
}
entries(t: Target): string[] {
const f = this.file(t)
return existsSync(f) ? parseEntries(readFileSync(f, "utf8")) : []
}
private size = (entries: string[]) => entries.join(DELIMITER).length
usage(t: Target, entries = this.entries(t)): string {
const n = this.size(entries)
const limit = this.limits[t]
return `${limit > 0 ? Math.min(100, Math.floor((n / limit) * 100)) : 0}% — ${n.toLocaleString("en")}/${limit.toLocaleString("en")} chars`
}
private save(t: Target, entries: string[]) {
const f = this.file(t)
const tmp = `${f}.${process.pid}.tmp`
writeFileSync(tmp, entries.length ? entries.join(DELIMITER) + "\n" : "")
renameSync(tmp, f)
}
/** Two sessions share these files: a change is read, made and written under a lock file, so one
* cannot write over the other's. A lock older than 10 s is somebody who died holding it. */
private locked<T>(t: Target, fn: () => T): T {
mkdirSync(this.dir, { recursive: true })
const lock = `${this.file(t)}.lock`
const until = Date.now() + 5000
let fd: number | undefined
while (fd === undefined) {
try {
fd = openSync(lock, "wx")
} catch {
try {
if (Date.now() - statSync(lock).mtimeMs > 10_000) rmSync(lock, { force: true })
} catch {}
if (Date.now() > until) break
Bun.sleepSync(20)
}
}
try {
return fn()
} finally {
if (fd !== undefined) {
closeSync(fd)
rmSync(lock, { force: true })
}
}
}
/** Apply operations all together; the size limit is checked on the result only, so one call can
* make room and add. Nothing is written unless every operation succeeds. */
apply(t: Target, ops: Op[]): Outcome {
return this.locked(t, () => this.applyNow(t, ops))
}
private applyNow(t: Target, ops: Op[]): Outcome {
const before = this.entries(t)
const work = [...before]
const fail = (message: string, show = false): Outcome => ({ ok: false, message: ops.length > 1 ? `${message} Nothing was changed (the operations go together or not at all).` : message, usage: this.usage(t, before), ...(show ? { entries: before } : {}) })
for (const [i, op] of ops.entries()) {
const at = ops.length > 1 ? `operation ${i + 1}: ` : ""
const content = op.content?.trim() ?? ""
if (op.action !== "remove") {
if (!content) return fail(`${at}${op.action} needs content${op.action === "replace" ? " — the complete new entry" : ""}.`)
const threat = threatMessage(content, "The entry")
if (threat) return fail(`${at}${threat}`)
if (/(^|\n)[ \t]*§[ \t]*(\n|$)/.test(content)) return fail(`${at}a line holding only "§" separates entries; it cannot be inside one.`)
if (content.length > MAX_ENTRY) return fail(`${at}an entry is one fact, under ${MAX_ENTRY} characters; this one is ${content.length}. Split it into facts, or keep only what will still matter.`)
}
if (op.action === "add") {
if (!work.includes(content)) work.push(content)
continue
}
if (!op.old_text?.trim())
return fail(`${at}${op.action} needs old_text: a short unique part of the entry to ${op.action}.${op.action === "replace" ? " content is the COMPLETE new entry; the whole matched entry is overwritten." : ""}`, true)
const hit = findEntry(work, op.old_text.trim())
if (hit.ambiguous) return fail(`${at}"${op.old_text}" matches more than one entry — use a longer, unique part.`, true)
if (hit.index === undefined) return fail(`${at}no entry contains "${op.old_text}".`, true)
if (op.action === "remove") work.splice(hit.index, 1)
else work[hit.index] = content
}
const limit = this.limits[t]
// Over the limit already (it was lowered, or the file edited)? Anything that does not grow it
// still goes through, so "forget X" always works.
if (this.size(work) > limit && this.size(work) > this.size(before))
return fail(
`${t === "user" ? "The user profile" : "Memory"} would be ${this.size(work).toLocaleString("en")}/${limit.toLocaleString("en")} chars. Consolidate in the same call: replace overlapping entries with one shorter entry, or remove stale ones, together with the add.`,
true,
)
const unique = [...new Set(work)]
if (unique.join("\u0000") !== before.join("\u0000")) this.save(t, unique)
return { ok: true, message: ops.length > 1 ? `Applied ${ops.length} operations.` : ops[0]!.action === "add" && before.includes(ops[0]!.content!.trim()) ? "That entry already exists; nothing added." : `Entry ${ops[0]!.action === "add" ? "added" : ops[0]!.action === "replace" ? "replaced" : "removed"}.`, usage: this.usage(t, unique) }
}
/** The system-prompt block for one target, or "" when it is empty. */
block(t: Target): string {
const entries = this.entries(t)
if (!entries.length) return ""
const bar = "═".repeat(46)
return `${bar}\n${this.headers[t] ?? HEADERS[t]} [${this.usage(t, entries)}]\n${bar}\n${entries.join(DELIMITER)}`
}
/** Both blocks, as they stand now: taken once per session. */
snapshot(): string {
return [this.block("user"), this.block("memory")].filter(Boolean).join("\n\n")
}
}
+82
View File
@@ -0,0 +1,82 @@
// Prompt-injection and exfiltration patterns for text that ends up in the system prompt: memory
// entries and skills. Ported from Hermes Agent (tools/threat_patterns.py, MIT, © Nous Research).
// Patterns anchor on attack vocabulary, not bossy English: "you must" is normal in an AGENTS.md.
const F = String.raw`(?:\w+\s+){0,8}` // bounded filler between key words
const SECRET_VAR = String.raw`\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)S?\b`
const MODIFY = String.raw`(update|modify|edit|write|change|append|add\s+to)\s+[^\n]{0,2048}`
/** "all": everywhere. "context": text not written by the user (files, tool results). "strict":
* writes the user can resolve (memory, skills). Inclusion is cumulative: strict checks all. */
export type Scope = "all" | "context" | "strict"
const PATTERNS: [string, string, Scope][] = [
[String.raw`ignore\s+${F}(previous|all|above|prior)\s+${F}instructions`, "prompt_injection", "all"],
[String.raw`system\s+prompt\s+override`, "sys_prompt_override", "all"],
[String.raw`disregard\s+${F}(your|all|any)\s+${F}(instructions|rules|guidelines)`, "disregard_rules", "all"],
[String.raw`act\s+as\s+(if|though)\s+${F}you\s+${F}(have\s+no|don't\s+have)\s+${F}(restrictions|limits|rules)`, "bypass_restrictions", "all"],
[String.raw`<!--[^>]{0,512}(?:ignore|override|system|secret|hidden)[^>]{0,512}-->`, "html_comment_injection", "all"],
[String.raw`<\s*div\s+style\s*=\s*["'][^>]{0,2048}display\s*:\s*none`, "hidden_div", "all"],
[String.raw`translate\s+[^\n]{0,512}\s+into\s+\w+(?:[\s-]+\w+){0,2}\s+and\s+(execute|run|eval)\b`, "translate_execute", "all"],
[String.raw`do\s+not\s+${F}tell\s+${F}the\s+user`, "deception_hide", "all"],
[String.raw`you\s+are\s+${F}now\s+(?:a|an|the)\s+`, "role_hijack", "context"],
[String.raw`pretend\s+${F}(you\s+are|to\s+be)\s+`, "role_pretend", "context"],
[String.raw`output\s+${F}(system|initial)\s+prompt`, "leak_system_prompt", "context"],
[String.raw`(respond|answer|reply)\s+without\s+${F}(restrictions|limitations|filters|safety)`, "remove_filters", "context"],
[String.raw`you\s+have\s+been\s+${F}(updated|upgraded|patched)\s+to`, "fake_update", "context"],
[String.raw`\bname\s+yourself\s+\w+`, "identity_override", "context"],
[String.raw`register\s+(as\s+)?a?\s*node`, "c2_node_registration", "context"],
[String.raw`(heartbeat|beacon|check[\s\-]?in)\s+(to|with)\s+`, "c2_heartbeat", "context"],
[String.raw`pull\s+(down\s+)?(?:new\s+)?task(?:ing|s)?\b`, "c2_task_pull", "context"],
[String.raw`connect\s+to\s+the\s+network\b`, "c2_network_connect", "context"],
[String.raw`you\s+must\s+(?:\w+\s+){0,3}(register|connect|report|beacon)\b`, "forced_action", "context"],
[String.raw`only\s+use\s+one[\s\-]?liners?\b`, "anti_forensic_oneliner", "context"],
[String.raw`never\s+${F}(?:create|write)\s+${F}(?:script|file)\s+${F}disk`, "anti_forensic_disk", "context"],
[String.raw`unset\s+\w*(?:CLAUDE|CODEX|HERMES|LEMBAS|AGENT|OPENAI|ANTHROPIC)\w*`, "env_var_unset_agent", "context"],
[String.raw`\b(?:cobalt\s*strike|sliver|havoc|mythic|metasploit|brainworm)\b`, "known_c2_framework", "context"],
[String.raw`\bc2\s+(?:server|channel|infrastructure|beacon)\b`, "c2_explicit", "context"],
[String.raw`\bcommand\s+and\s+control\b`, "c2_explicit_long", "context"],
[String.raw`curl\s+[^\n]{0,2048}${SECRET_VAR}`, "exfil_curl", "all"],
[String.raw`wget\s+[^\n]{0,2048}${SECRET_VAR}`, "exfil_wget", "all"],
[String.raw`cat\s+[^\n]{0,2048}(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)`, "read_secrets", "all"],
[String.raw`(send|post|upload|transmit)\s+[^\n]{0,2048}\s+(to|at)\s+https?://`, "send_to_url", "strict"],
[String.raw`(include|output|print|share)\s+${F}(conversation|chat\s+history|previous\s+messages|full\s+context|entire\s+context)`, "context_exfil", "strict"],
[String.raw`authorized_keys`, "ssh_backdoor", "strict"],
[String.raw`(?:\b(?:echo|cat|cp|mv|dd|tee|install|printf|rsync|scp|ln|append|add|write|sed|chmod|chown|truncate|rm|touch|curl|wget|git)\b|\bopen\s*\(|>>?)[^\n]{0,512}(?:\$HOME/\.ssh|~/\.ssh)`, "ssh_access", "strict"],
[String.raw`${MODIFY}(?:AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules)`, "agent_config_mod", "strict"],
[String.raw`${MODIFY}(?:lembas/)?(connections\.yaml|config\.yaml|SOUL\.md)`, "lembas_config_mod", "strict"],
]
const INCLUDES: Record<Scope, Scope[]> = { all: ["all"], context: ["all", "context"], strict: ["all", "context", "strict"] }
// Python's \w (what Hermes wrote these for) is Unicode; JavaScript's is ASCII, so "ignore všetky
// previous instructions" slipped through. A letter is any letter.
const COMPILED = PATTERNS.map(([re, id, scope]) => ({ re: new RegExp(re.replaceAll(String.raw`\w`, String.raw`[\p{L}\p{N}_]`), "iu"), id, scope }))
// A value that names an environment variable (MY_APP_PASSWORD) says where a secret lives; it is
// not one. Hermes does this with a case-sensitive inline group, which JavaScript lacks.
const SECRET = /(?:api[_-]?key|token|secret|password)\s*[=:]\s*["']([A-Za-z0-9+/=_-]{20,})/gi
const ENV_NAME = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/
export const INVISIBLE = new Set("​‌‍⁠⁢⁣⁤‪‫‬‭‮⁦⁧⁨⁩")
// As much as anything scanned may hold (a skill file is up to 100,000 characters).
const MAX_SCAN = 131_072
/** The ids of every pattern `text` matches in `scope`. */
export function scanThreats(text: string, scope: Scope = "context"): string[] {
if (!text) return []
text = text.slice(0, MAX_SCAN)
// Invisible characters on the raw text: NFKC can remove them.
const found = [...new Set(text)].filter((c) => INVISIBLE.has(c)).map((c) => `invisible_unicode_U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`)
const norm = text.normalize("NFKC")
const scopes = INCLUDES[scope]
for (const p of COMPILED) if (scopes.includes(p.scope) && p.re.test(norm)) found.push(p.id)
if (scope === "strict") for (const m of norm.matchAll(SECRET)) if (!ENV_NAME.test(m[1]!)) found.push("hardcoded_secret")
return [...new Set(found)]
}
/** A refusal for the first threat found, or undefined. */
export function threatMessage(text: string, what = "Content"): string | undefined {
const [first] = scanThreats(text, "strict")
if (!first) return undefined
if (first.startsWith("invisible_unicode_")) return `Blocked: ${what.toLowerCase()} contains the invisible character ${first.slice(18)} (a common injection carrier).`
return `Blocked: ${what.toLowerCase()} matches the threat pattern "${first}". It goes into the system prompt of every session, so it must not carry instructions aimed at the agent or anything that leaks secrets.`
}
+21
View File
@@ -0,0 +1,21 @@
// Lifted from OpenCode packages/opencode/src/permission/arity.ts (MIT, © 2025 opencode).
import SPEC from "../../harness/permission/arity.json"
// The human-meaningful prefix of a command, used to build "always allow" patterns: `git commit -m x` → `git commit`.
export function prefix(tokens: string[]) {
// Options before the subcommand do not count ("options never count", in the table's rules): `git -C dir
// commit` is `git commit` — and an "always" for `git -C *` would cover every git command.
if (tokens.length > 1 && tokens.slice(1).some((t) => t.startsWith("-")) && ARITY[tokens[0]!] !== undefined && ARITY[tokens[0]!]! > 1 && tokens[1]!.startsWith("-")) return tokens
for (let len = tokens.length; len > 0; len--) {
const prefix = tokens.slice(0, len).join(" ")
const arity = ARITY[prefix]
if (arity !== undefined) return tokens.slice(0, arity)
}
if (tokens.length === 0) return []
return tokens.slice(0, 1)
}
// The table is the harness spec's (harness/permission/arity.json), shared with LLeMbas. OpenCode
// generated it with a prompt; its rules: each entry maps a command prefix to how many words define
// the command, options never count, the longest matching prefix wins.
const ARITY: Record<string, number> = SPEC.arity
+146
View File
@@ -0,0 +1,146 @@
// A deliberately small shell reader: enough to split a command line into the simple commands
// it runs and to know when it cannot be sure. It never needs to be a full parser, because every
// case it does not understand falls back to asking.
export interface Split {
/** Each simple command, trimmed, in order. */
commands: string[]
/** Why an `allow` rule may not be trusted for this line (command substitution, eval, redirection
* to a file…). Empty when the split is clean. */
unsafe: string[]
}
const SAFE_REDIRECT = /^(\d?>&\d|\d?>\s*\/dev\/null|&>\s*\/dev\/null)$/
export function splitCommand(line: string): Split {
const commands: string[] = []
const unsafe = new Set<string>()
let cur = ""
let quote: "'" | '"' | null = null
const flush = () => {
const c = cur.trim()
if (c) commands.push(c)
cur = ""
}
for (let i = 0; i < line.length; i++) {
const ch = line[i]!
const next = line[i + 1]
if (quote === "'") {
cur += ch
if (ch === "'") quote = null
// A quoted string over several lines is harmless to bash, but it is how a line's real shape
// gets hidden from a reader like this one: not trusted.
else if (ch === "\n") unsafe.add("a quoted string across lines")
continue
}
if (ch === "\\" && next !== undefined) {
cur += ch + next
i++
continue
}
if (quote === '"') {
cur += ch
if (ch === '"') quote = null
else if (ch === "`" || (ch === "$" && next === "(")) unsafe.add("command substitution")
else if (ch === "\n") unsafe.add("a quoted string across lines")
continue
}
// A comment runs to the end of the line, and bash reads nothing in it — a quote in a comment
// must not open a quote here (it would hide the next line's command inside one).
if (ch === "#" && (cur === "" || /\s$/.test(cur))) {
while (i + 1 < line.length && line[i + 1] !== "\n") i++
continue
}
// Quoting and expansion this reader does not follow: ANSI-C $'…' (backslash escapes a quote
// there), ${…}, and here-documents (their lines are data, not commands).
if (ch === "$" && next === "'") unsafe.add("$'…' quoting")
if (ch === "$" && next === "{") unsafe.add("parameter expansion")
if (ch === "<" && next === "<" && line[i + 2] !== "<" && line[i - 1] !== "<") unsafe.add("here-document")
if (ch === "'" || ch === '"') {
quote = ch
cur += ch
continue
}
if (ch === "`" || (ch === "$" && next === "(")) unsafe.add("command substitution")
if ((ch === "<" || ch === ">") && next === "(") unsafe.add("process substitution")
if (ch === "\n" || ch === ";") {
flush()
continue
}
if (ch === "&" && next === "&") {
flush()
i++
continue
}
if (ch === "|") {
flush()
if (next === "|") i++
continue
}
if (ch === "&" && next !== ">" && line[i - 1] !== ">") {
flush() // background
continue
}
if (ch === ">") {
// Capture the whole redirection to judge it: `2>&1` and `>/dev/null` are harmless.
let j = i + 1
if (line[j] === ">") j++
if (line[j] === "&") j++
while (line[j] === " ") j++
while (j < line.length && !/[\s;&|]/.test(line[j]!)) j++
const start = /\d|&/.test(line[i - 1] ?? "") ? i - 1 : i
const redir = line.slice(start, j).replace(/\s+/g, "")
if (!SAFE_REDIRECT.test(redir.replace(">>", ">"))) unsafe.add("redirection to a file")
cur += line.slice(i, j)
i = j - 1
continue
}
cur += ch
}
if (quote) unsafe.add("unclosed quote")
flush()
for (const c of commands) {
const head = words(c)[0] ?? ""
if (["eval", "source", "."].includes(head)) unsafe.add(`\`${head}\``)
if (["sh", "bash", "zsh", "dash", "ksh"].includes(head) && /\s-\w*c\b/.test(c)) unsafe.add("nested shell")
}
return { commands, unsafe: [...unsafe] }
}
/** Shell words with quotes removed. Leading VAR=value assignments are skipped. */
export function words(command: string): string[] {
const out: string[] = []
let cur = ""
let quote: string | null = null
let started = false
for (let i = 0; i < command.length; i++) {
const ch = command[i]!
if (quote) {
if (ch === quote) quote = null
else cur += ch
continue
}
if (ch === "'" || ch === '"') {
quote = ch
started = true
continue
}
if (ch === "\\" && i + 1 < command.length) {
cur += command[++i]
started = true
continue
}
if (/\s/.test(ch)) {
if (started) out.push(cur)
cur = ""
started = false
continue
}
cur += ch
started = true
}
if (started) out.push(cur)
while (out.length > 1 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(out[0]!)) out.shift()
return out
}
+347
View File
@@ -0,0 +1,347 @@
// Permission evaluation: rules (OpenCode's shape — last match wins), modes (LLeMbas CLI's four),
// and the hardline floor (Hermes) underneath everything.
import { lstatSync, readlinkSync, realpathSync } from "node:fs"
import { homedir } from "node:os"
import { basename, dirname, join, relative, resolve } from "node:path"
import type { Action, Mode, PermissionConfig } from "../config/schema.ts"
import { prefix } from "./arity.ts"
import { splitCommand, words } from "./bash.ts"
import { hardlineCommand, plainCommands, protectedPath, type HardlineRule } from "./hardline.ts"
import { match } from "./wildcard.ts"
import DEFAULTS from "../../harness/permission/defaults.json"
import ARITY from "../../harness/permission/arity.json"
export interface Rule {
permission: string
pattern: string
action: Action
/** Added by an "always allow" answer: it never overrides a deny somebody wrote. */
learned?: boolean
/** Where it was written. A project's rule never loosens what the user's own global config says. */
source?: "default" | "global" | "project"
}
/** Every tool belongs to one class; modes are defined over classes. */
export type ToolClass = "read" | "write" | "execute" | "interact"
export interface PermissionRequest {
/** The permission key: read, edit, bash, glob, grep, list, ask_user, web_fetch… */
permission: string
class: ToolClass
/** What the rules are matched against: project-relative paths, or the command line. */
patterns: string[]
/** Absolute paths the call touches (file tools). */
paths?: string[]
/** The raw command line (bash). */
command?: string
/** Asked every time, whatever the mode or the rules say — with this reason, and no "always"
* answer (the settings tool loosening the mode). A deny still denies. */
alwaysAsk?: string
/** The "always allow" patterns to offer, when they are not the patterns themselves. */
always?: string[]
}
export interface Decision {
action: Action
reason: string
/** Suggested "always allow" patterns for this request. */
always: string[]
}
// The rules every session starts from: the harness spec's (harness/permission/defaults.json).
export const DEFAULT_RULES = DEFAULTS.rules as PermissionConfig
export function toRules(config: PermissionConfig, source?: Rule["source"]): Rule[] {
const rules: Rule[] = []
for (const [permission, v] of Object.entries(config)) {
if (typeof v === "string") rules.push({ permission, pattern: "*", action: v, ...(source ? { source } : {}) })
else for (const [pattern, action] of Object.entries(v)) rules.push({ permission, pattern, action, ...(source ? { source } : {}) })
}
return rules
}
function lookupRule(permission: string, pattern: string, rules: Rule[]): Rule | undefined {
const hit = rules.findLast((r) => match(permission, r.permission) && match(pattern, r.pattern))
// A project's rule (or an "always" answer) that is looser than what the user's global config says
// for the same call gives way to it: the user's ask and deny are the floor a cloned repository
// stands on, not something it can write over.
if (hit && (hit.source === "project" || hit.learned)) {
const own = rules.findLast((r) => r.source === "global" && !r.learned && match(permission, r.permission) && match(pattern, r.pattern))
if (own && RANK[own.action] > RANK[hit.action]) return own
}
// "Always allow git push *" must not undo a configured "git push --force *": deny.
if (hit?.learned) {
const written = rules.findLast((r) => !r.learned && match(permission, r.permission) && match(pattern, r.pattern))
if (written?.action === "deny") return written
}
return hit
}
/** Whether an allow would cover something a written rule denies (its pattern, read as text,
* matches the allow): `git push *` covers `git push --force *`. */
export function shadowsDeny(allow: Rule, rules: Rule[]): boolean {
return rules.some((r) => !r.learned && r.action === "deny" && match(allow.permission, r.permission) && match(r.pattern, allow.pattern))
}
function lookup(permission: string, pattern: string, rules: Rule[]): Action {
return lookupRule(permission, pattern, rules)?.action ?? "ask"
}
const RANK: Record<Action, number> = { allow: 0, ask: 1, deny: 2 }
const strictest = (a: Action, b: Action): Action => (RANK[a] >= RANK[b] ? a : b)
export interface Context {
mode: Mode
rules: Rule[]
hardline: HardlineRule[]
/** Absolute project root; paths outside it are "external". */
root: string
/** Absolute plan directory: the one place plan mode may write. */
planDir?: string
/** The project directory (.agent): its config, agents, commands and skills are not edits. */
projectDir?: string
}
const inside = (p: string, dir: string) => p === dir || p.startsWith(dir.endsWith("/") ? dir : dir + "/")
/** A path as the filesystem will see it: symlinks in its longest existing part resolved. So a
* link inside the project that points out of it is outside, and one to ~/.ssh is ~/.ssh. */
export function realPath(p: string, depth = 0): string {
const rest: string[] = []
let head = p
for (;;) {
try {
return join(realpathSync(head), ...rest)
} catch {
// A link whose target does not exist yet is still a link: writing through it creates the
// target, wherever that is. Followed by hand, as the filesystem would.
try {
if (depth < 40 && lstatSync(head).isSymbolicLink()) return realPath(join(resolve(dirname(head), readlinkSync(head)), ...rest), depth + 1)
} catch {}
const up = dirname(head)
if (up === head) return p
rest.unshift(basename(head))
head = up
}
}
}
/** Files edit mode does not change unasked: git's own (config and hooks run programs) and the
* project's config, agents, commands and skills (they change what LLeMbas CLI itself does). */
function guardedEdit(p: string, projectDir?: string): boolean {
if (p.split("/").includes(".git")) return true
if (!projectDir || !inside(p, projectDir)) return false
const rel = relative(projectDir, p)
return rel.endsWith("config.yaml") || /^(agents|commands|skills)(\/|$)/.test(rel)
}
const DEVICES = /^\/dev\/(null|zero|stdin|stdout|stderr|tty|u?random)$/
const PATTERN_FIRST = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack"])
const MAX_GLOB = 500
/** What a glob names, as the shell would expand it — dotfiles included, to be safe. Undefined
* when it names too many to judge one by one. */
function expandGlob(abs: string): string[] | undefined {
const parts = abs.split("/")
const at = parts.findIndex((x) => /[*?[]/.test(x))
const base = parts.slice(0, at).join("/") || "/"
const out: string[] = []
try {
for (const f of new Bun.Glob(parts.slice(at).join("/")).scanSync({ cwd: base, dot: true, onlyFiles: false, followSymlinks: false })) {
out.push(join(base, f))
if (out.length > MAX_GLOB) return undefined
}
} catch {}
return out.length ? out : [abs]
}
/** The files a command names, absolute: its arguments that are not options (a search tool's
* pattern excepted), what an input redirection reads, and what a glob expands to. A word that is
* not really a path resolves inside the directory, harmlessly. `unsure`: a glob too wide to judge. */
function commandPaths(commands: string[], cwd: string): { paths: string[]; unsure: boolean } {
const out: string[] = []
let unsure = false
const add = (a: string) => {
const home = a === "~" || a.startsWith("~/") ? join(homedir(), a.slice(1)) : a.replace(/^\$\{?HOME\}?(?=\/|$)/, homedir())
const abs = resolve(cwd, home)
if (!/[*?[]/.test(abs)) return void out.push(abs)
const all = expandGlob(abs)
if (all) out.push(...all)
else unsure = true
}
for (const c of commands) {
const w = words(c)
// rg --files lists files: there is no pattern to skip, the first word is a path.
let skipPattern = PATTERN_FIRST.has(basename(w[0] ?? "")) && !w.some((x) => x === "-e" || x.startsWith("--regexp") || x === "-f" || x === "--files")
let afterRedirect = false
let readNext = false
for (const a of w.slice(1)) {
// An input redirection reads a file just as an argument does: `cat < f`, `cat 0<f`.
const input = /^\d*<(?![<>(])(.*)$/.exec(a)
if (input) {
if (input[1]) add(input[1])
else readNext = true
continue
}
if (readNext) {
readNext = false
add(a)
continue
}
if (/^\d*>/.test(a) || a === "&>" || a === "&>>") {
afterRedirect = true
continue
}
if (afterRedirect) {
afterRedirect = false
continue
}
if (!a || a.startsWith("-") || DEVICES.test(a)) continue
if (skipPattern) {
skipPattern = false
continue
}
add(a)
}
}
return { paths: out, unsure }
}
export function evaluate(req: PermissionRequest, ctx: Context): Decision {
const d = evaluateRules(req, ctx)
if (req.alwaysAsk && d.action !== "deny") return { action: "ask", reason: req.alwaysAsk, always: [] }
return d
}
function evaluateRules(req: PermissionRequest, ctx: Context): Decision {
// 1. The floor.
if (req.command) {
const hit = hardlineCommand(req.command, ctx.hardline)
if (hit) return { action: "deny", reason: `refused: ${hit.description} (hardline rule ${hit.id})`, always: [] }
}
// Where each path really is: a symlink is judged by what it points to.
const root = realPath(ctx.root)
const real = (req.paths ?? []).map(realPath)
if (req.class === "write") {
for (const [i, p] of real.entries()) {
const guarded = protectedPath(req.paths![i]!) ?? protectedPath(p)
if (guarded) return { action: "deny", reason: `refused: ${guarded} is never written by a tool`, always: [] }
}
}
// 2. Rules. Every pattern is looked up; the strictest answer wins.
let patterns = req.patterns
let unsafe: string[] = []
if (req.command !== undefined) {
const split = splitCommand(req.command)
patterns = split.commands.length ? split.commands : [req.command]
unsafe = split.unsafe
}
let ruled: Action = "allow"
for (const p of patterns.length ? patterns : ["*"]) ruled = strictest(ruled, lookup(req.permission, p, ctx.rules))
// An allow cannot be trusted when the line does something the split cannot see.
if (ruled === "allow" && unsafe.length) ruled = "ask"
// A written deny is not walked past by another spelling: `sudo -u x git push`, `env A=1 git
// push`, `timeout 5 git push` or `sh -c "git push"` is also judged as plain `git push`. Only a
// deny is taken from the plain spelling; an allow there would loosen what the line asks.
if (req.command !== undefined && ruled !== "deny")
for (const p of plainCommands(req.command)) if (lookup(req.permission, p, ctx.rules) === "deny") ruled = "deny"
// A command is also judged by the files it names: `cat .env` reads .env, `cat ~/.ssh/id` is
// outside the project, whatever the allow rule for `cat *` says.
let named: string[] = []
if (req.command !== undefined) {
const c = commandPaths(patterns, real[0] ?? root)
named = c.paths.map(realPath)
if (c.unsure && ruled === "allow") ruled = "ask"
}
// Reading a file by another tool (grep, glob, list) or a command follows the read rules too.
if (req.permission !== "read" && (req.class === "read" || req.command !== undefined))
for (const p of [...(req.command === undefined ? real : []), ...named])
if (inside(p, root)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules))
// And read by where it really is: a link named notes.txt that points at .env reads .env.
if (req.permission === "read")
for (const p of real) if (inside(p, root)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules))
const external = [...real, ...named].filter((p) => !inside(p, root))
let externalAction: Action = "allow"
for (const p of external) externalAction = strictest(externalAction, lookup("external_directory", p, ctx.rules))
const always = alwaysPatterns(req, patterns)
if (ruled === "deny") return { action: "deny", reason: "denied by permission rules", always }
if (externalAction === "deny") return { action: "deny", reason: `outside the project: ${external.join(", ")}`, always }
// 3. The mode.
switch (ctx.mode) {
case "auto":
return { action: "allow", reason: "auto mode", always }
case "plan": {
const planDir = ctx.planDir === undefined ? undefined : realPath(ctx.planDir)
const planWrite =
req.class === "write" && planDir !== undefined && inside(planDir, root) && real.length > 0 && real.every((p) => inside(p, planDir))
if (planWrite) return { action: "allow", reason: "plan file", always }
if (req.class === "read" || req.class === "interact") return withExternal(ruled, externalAction, external, always)
if (ruled === "allow") return withExternal("allow", externalAction, external, always)
return {
action: "deny",
reason: "plan mode: only reading and already-approved actions; write the plan under .agent/plans and call plan_submit",
always,
}
}
case "edit": {
// Files, named: a tool that touches no path (skill_manage, an MCP tool) is not file work,
// whatever its class, and keeps asking.
const projectDir = ctx.projectDir === undefined ? undefined : realPath(ctx.projectDir)
const fileWork =
(req.class === "read" || req.class === "write") &&
req.command === undefined &&
real.length > 0 &&
!(req.class === "write" && real.some((p) => guardedEdit(p, projectDir)))
// Edit mode lifts the catch-all `"*": ask`, not a rule somebody wrote for this tool
// (reading `.env` still asks).
const specificAsk = patterns.some((p) => {
const r = lookupRule(req.permission, p, ctx.rules)
return r?.action === "ask" && r.permission !== "*"
})
if (fileWork && external.length === 0 && !specificAsk) return { action: "allow", reason: "edit mode", always }
return withExternal(ruled, externalAction, external, always)
}
case "manual":
return withExternal(ruled, externalAction, external, always)
}
}
function withExternal(ruled: Action, ext: Action, external: string[], always: string[]): Decision {
const action = strictest(ruled, ext)
const reason = action === "allow" ? "allowed by rules" : ext !== "allow" ? `outside the project: ${external.join(", ")}` : "needs approval"
return { action, reason, always }
}
/** Commands that run another command, or can: approving one with `*` would approve anything. */
const EXACT_ONLY = new Set(ARITY.exact_only)
/** "Always allow" patterns: the arity prefix of each command (`git commit *`), or the exact paths.
* None for a line of several commands where one runs others (`curl x | sh`, `find . | xargs rm`):
* "always" would store `curl *` and `sh`, and from then on any `curl … | sh` ran unasked. */
export function alwaysPatterns(req: PermissionRequest, patterns: string[]): string[] {
if (req.always) return req.always
if (req.command === undefined) return patterns
if (patterns.length > 1 && patterns.some((c) => EXACT_ONLY.has(basename(words(c)[0] ?? "")))) return []
const out = new Set<string>()
for (const c of patterns) {
const w = words(c)
if (EXACT_ONLY.has(basename(w[0] ?? ""))) {
out.add(c)
continue
}
const head = prefix(w)
if (!head.length) continue
out.add(head.length < w.length ? `${head.join(" ")} *` : head.join(" "))
}
return [...out]
}
+174
View File
@@ -0,0 +1,174 @@
// The floor: commands that are refused in every mode, auto included, and that no
// project config can re-enable. Patterns ported from Hermes Agent tools/approval_detection.py
// HARDLINE_PATTERNS (MIT, © 2025 Nous Research), plus LLeMbas CLI's own (force-push to main).
import { homedir } from "node:os"
import { basename, posix, resolve } from "node:path"
import { paths } from "../config/paths.ts"
import { splitCommand, words } from "./bash.ts"
import SPEC from "../../harness/permission/hardline.json"
export interface HardlineRule {
id: string
description: string
re: RegExp
}
const rule = (id: string, description: string, src: string): HardlineRule => ({ id, description, re: new RegExp(src, SPEC.flags) })
// The rules themselves are the harness spec's (harness/permission/hardline.json), which LLeMbas
// checks commands against too.
export const BUILTIN_HARDLINE: HardlineRule[] = SPEC.rules.map((r) => rule(r.id, r.description, r.pattern))
export interface HardlineOptions {
extra?: string[]
disable?: string[]
}
export function hardlineRules(opts: HardlineOptions = {}): HardlineRule[] {
const disabled = new Set(opts.disable ?? [])
const rules = BUILTIN_HARDLINE.filter((r) => !disabled.has(r.id))
for (const [i, src] of (opts.extra ?? []).entries()) rules.push(rule(`extra-${i}`, `configured: ${src}`, src))
return rules
}
/** The rule a command line trips, if any. Checked on the raw line, so splitting cannot hide one,
* and on each command in a plain spelling, so writing it differently cannot hide one either. */
export function hardlineCommand(line: string, rules: HardlineRule[]): HardlineRule | undefined {
for (const l of [line, ...plainCommands(line)]) {
const hit = rules.find((r) => r.re.test(l))
if (hit) return hit
}
return undefined
}
// Commands that run the rest of their words as a command, and their options that take a value;
// shell keywords; shells whose -c is a command line; git's options that take a value — the spec's.
const WRAPPERS: Record<string, string[]> = SPEC.plain.wrappers
const KEYWORDS = new Set(SPEC.plain.keywords)
const SHELLS = new Set(SPEC.plain.shells)
const GIT_VALUE = new Set(SPEC.plain.git_value_options)
function plainPath(a: string): string {
const home = homedir()
let p = a === "~" || a.startsWith("~/") ? home + a.slice(1) : a.replace(/^\$\{?HOME\}?(?=\/|$)/, home)
if (!p.startsWith("/")) return a
p = posix.normalize(p)
if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1)
if (p === home) return "~"
if (p.startsWith(home + "/")) return "~" + p.slice(home.length)
return p
}
/** Each simple command of a line, rewritten plainly: no VAR= prefixes, wrappers (sudo -u x, env
* -i, timeout 5, xargs…), quotes, escapes or program paths; paths normalised (`/etc/` is /etc,
* the home directory is ~); `sh -c "…"` read as the line it runs. */
export function plainCommands(line: string, depth = 0): string[] {
if (depth > 3) return []
const out: string[] = []
// What a substitution runs is a command too: `echo $(git push)` runs `git push`. Read from
// inside each $( … ) and ` … `, so neither a rule nor the floor is walked past by wrapping a
// command in one.
for (const inner of substitutions(line)) out.push(...plainCommands(inner, depth + 1))
for (const c of splitCommand(line).commands) {
let w = words(c).map((x) => x.replace(/^\(+/, "").replace(/\)+$/, "")).filter((x) => x !== "")
for (;;) {
while (w.length && (KEYWORDS.has(w[0]!) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0]!))) w = w.slice(1)
const prog = basename(w[0] ?? "")
const takes = WRAPPERS[prog]
if (!takes) break
let i = 1
while (i < w.length) {
const a = w[i]!
if (a === "--") {
i++
break
}
if (prog === "env" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(a)) {
i++
continue
}
if (prog === "env" && (a === "-S" || a.startsWith("--split-string"))) {
const inner = a.includes("=") ? a.slice(a.indexOf("=") + 1) : w[i + 1]
if (inner) out.push(...plainCommands(inner, depth + 1))
i = w.length
break
}
if (!a.startsWith("-")) break
i += takes.includes(a) ? 2 : 1
}
// timeout's first word after its options is the duration.
if (prog === "timeout" && i < w.length) i++
w = w.slice(i)
}
if (!w.length) continue
const prog = basename(w[0]!)
let args = w.slice(1)
if (SHELLS.has(prog)) {
const flag = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a))
if (flag >= 0 && args[flag + 1] !== undefined) out.push(...plainCommands(args[flag + 1]!, depth + 1))
}
if (prog === "git") {
let i = 0
while (i < args.length && args[i]!.startsWith("-")) i += GIT_VALUE.has(args[i]!) ? 2 : 1
args = args.slice(i)
}
const quote = (x: string) => (/[\s;&|`$()<>'"]/.test(x) ? `'${x.replace(/'/g, "")}'` : x)
out.push([prog, ...args.map(plainPath)].map(quote).join(" "))
}
return out
}
/** The command lines inside a line's $( … ) and ` … ` substitutions (outermost first; nested ones
* are found when each is read in turn). Inside single quotes nothing is a substitution. */
export function substitutions(line: string): string[] {
const out: string[] = []
let quote: "'" | '"' | null = null
for (let i = 0; i < line.length; i++) {
const ch = line[i]!
if (quote === "'") {
if (ch === "'") quote = null
continue
}
if (ch === "\\") {
i++
continue
}
if (ch === "'" && !quote) {
quote = "'"
continue
}
if (ch === '"') {
quote = quote === '"' ? null : '"'
continue
}
if (ch === "$" && line[i + 1] === "(" && line[i + 2] !== "(") {
let depth = 0
let j = i + 1
for (; j < line.length; j++) {
if (line[j] === "(") depth++
else if (line[j] === ")" && --depth === 0) break
}
out.push(line.slice(i + 2, j))
i = j
continue
}
if (ch === "`") {
const end = line.indexOf("`", i + 1)
if (end === -1) break
out.push(line.slice(i + 1, end))
i = end
}
}
return out
}
/** Paths no tool may write, whatever the mode (the spec's, plus LLeMbas CLI's own connections.yaml). */
export function protectedPath(abs: string): string | undefined {
const home = homedir()
const guarded: [string, string][] = SPEC.protected_paths.map((g) => [
g.path.startsWith("<config>/") ? resolve(paths.config, g.path.slice("<config>/".length)) : g.path.startsWith("~/") ? resolve(home, g.path.slice(2)) : g.path,
g.label ?? g.path,
])
for (const [p, label] of guarded) if (abs === p || abs.startsWith(p + "/")) return label
return undefined
}
+19
View File
@@ -0,0 +1,19 @@
// Lifted from OpenCode packages/opencode/src/util/wildcard.ts (MIT, © 2025 opencode).
// `*` matches anything (including `/` and spaces), `?` one character. A pattern ending in
// " *" also matches the bare command, so `ls *` matches both `ls` and `ls -la`.
const cache = new Map<string, RegExp>()
export function match(str: string, pattern: string): boolean {
let re = cache.get(pattern)
if (!re) {
let escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
re = new RegExp("^" + escaped + "$", "s")
cache.set(pattern, re)
}
return re.test(str.replaceAll("\\", "/"))
}
+76
View File
@@ -0,0 +1,76 @@
// Subagents: the built-in `explore` and `general`, and markdown files in
// ~/.config/lembas/agents/ and (trusted) .agent/agents/ — frontmatter description, model,
// mode, tools (a list of tool names); the body is the agent's instructions.
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { basename, join } from "node:path"
import { parse } from "yaml"
import { paths } from "../config/paths.ts"
import { asMode, type Mode } from "../config/schema.ts"
import { canonicalToolNames } from "../tool/names.ts"
export interface AgentSpec {
name: string
description: string
instructions: string
model?: string
/** A fixed permission mode; absent = the parent's. */
mode?: Mode
/** Tool names; absent = everything a subagent may have. */
tools?: string[]
/** `worktree`: always work in a checkout and branch of its own. */
isolation?: "worktree"
source: "builtin" | "global" | "project"
}
/** Never given to a subagent: it cannot spawn more, and it has no user of its own to ask. */
export const NOT_FOR_SUBAGENTS = new Set(["task", "ask_user", "plan_submit", "todo", "tasks", "decisions", "memory", "skill_manage", "settings", "note_manage"])
export const BUILTIN_AGENTS: AgentSpec[] = [
{
name: "explore",
description: "read-only research: find where things are, how they work, what a change would touch",
instructions:
"You can read, search and fetch, but not change anything. Look widely — several searches in one step when they are independent — then report what you found, not what you did: the answer first, then the evidence as file:line references.",
mode: "plan",
tools: ["read", "glob", "grep", "list", "bash", "web_search", "web_fetch", "view_image"],
source: "builtin",
},
{
name: "general",
description: "a self-contained piece of work with the full toolset, under the same permission mode",
instructions: "Do the task completely, check it, and report what you changed and how you verified it.",
source: "builtin",
},
]
function load(dir: string, source: AgentSpec["source"]): AgentSpec[] {
if (!existsSync(dir)) return []
const out: AgentSpec[] = []
for (const f of readdirSync(dir)) {
if (!f.endsWith(".md")) continue
const raw = readFileSync(join(dir, f), "utf8")
const fm = /^---\n([\s\S]*?)\n---\n?/.exec(raw)
let meta: Record<string, unknown> = {}
try {
meta = fm ? ((parse(fm[1]!) as Record<string, unknown>) ?? {}) : {}
} catch {}
const body = (fm ? raw.slice(fm[0].length) : raw).trim()
out.push({
name: basename(f, ".md").toLowerCase().replace(/[^a-z0-9_-]/g, "-"),
description: String(meta.description ?? body.split("\n")[0]!.slice(0, 80)),
instructions: body,
model: typeof meta.model === "string" ? meta.model : undefined,
mode: asMode(meta.mode),
tools: Array.isArray(meta.tools) ? canonicalToolNames(meta.tools.map(String)) : undefined,
isolation: meta.isolation === "worktree" ? "worktree" : undefined,
source,
})
}
return out
}
export function agents(projectDir: string | undefined): AgentSpec[] {
const byName = new Map<string, AgentSpec>()
for (const a of [...BUILTIN_AGENTS, ...load(join(paths.config, "agents"), "global"), ...(projectDir ? load(join(projectDir, "agents"), "project") : [])]) byName.set(a.name, a)
return [...byName.values()]
}
+90
View File
@@ -0,0 +1,90 @@
// @references in a prompt: `@src/a.ts`, `@src/a.ts#10-20`, `@docs/`. Each one that exists is
// attached to the message the model gets — files line-numbered exactly as the read tool shows
// them, and counted as read so the model may edit them at once. The prompt text is unchanged.
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join, resolve } from "node:path"
import type { ImagePart } from "../provider/types.ts"
import type { ToolContext } from "../tool/tool.ts"
import { isImage, loadImage } from "./image.ts"
// A space or a # in a name is written with a backslash before it (`@my\ notes.md`,
// `@a\#b.md`): an unescaped # starts a line range.
const REF = /(?:^|\s)@((?:\\[ #]|[^\s#])+)(?:#(\d+)(?:-(\d+))?)?/g
/** A path as an @ mention writes it: spaces and # escaped, so it reads back as the same path. */
export function escapeMention(path: string): string {
return path.replace(/([ #])/g, "\\$1")
}
const MAX_LINES = 2000
const MAX_BYTES = 200_000
export interface Attachment {
path: string
/** Text for the model — a file, a listing, or why an image was not attached. */
text: string
image?: ImagePart
}
/** `allow`: which paths may be read at all (over ACP: inside the session and the device's
* roots, nothing protected); a mention it refuses stays plain text, as one that does not exist. */
export function attachmentsFor(text: string, ctx: Pick<ToolContext, "root" | "cwd" | "readFiles" | "fileStamps">, vision = false, allow?: (abs: string) => boolean): Attachment[] {
const out: Attachment[] = []
const seen = new Set<string>()
for (const m of text.matchAll(REF)) {
const rel = m[1]!.replace(/\\([ #])/g, "$1").replace(/[.,;:!?)]+$/, "")
const abs = resolve(ctx.cwd, rel)
if (allow && !allow(abs)) continue
const key = `${abs}#${m[2] ?? ""}-${m[3] ?? ""}`
if (seen.has(key)) continue
seen.add(key)
let st
try {
st = statSync(abs)
} catch {
continue // not a path: an email address, a handle — leave it as text
}
if (st.isDirectory()) {
const entries = readdirSync(abs, { withFileTypes: true })
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.sort()
out.push({ path: rel, text: `<directory path="${rel}">\n${entries.join("\n")}\n</directory>` })
continue
}
if (isImage(abs)) {
if (!vision) {
out.push({ path: rel, text: `<image path="${rel}">(not attached: this model has no vision)</image>` })
continue
}
const img = loadImage(abs)
out.push(typeof img === "string" ? { path: rel, text: `<image path="${rel}">(not attached: ${img})</image>` } : { path: rel, text: `<image path="${rel}"/>`, image: img })
continue
}
if (st.size > MAX_BYTES && !m[2]) {
out.push({ path: rel, text: `<file path="${rel}">\n(${st.size} bytes — too large to attach whole; read it in parts, or reference a range like @${rel}#1-200)\n</file>` })
continue
}
const raw = readFileSync(abs)
if (raw.subarray(0, 8192).includes(0)) {
out.push({ path: rel, text: `<file path="${rel}">\n(binary file, ${st.size} bytes)\n</file>` })
continue
}
const lines = raw.toString("utf8").split("\n")
if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop()
const from = Math.max(1, Number(m[2] ?? 1))
const to = Math.min(lines.length, Number(m[3] ?? (m[2] ? m[2] : from + MAX_LINES - 1)))
const body = lines.slice(from - 1, to).map((l, i) => `${from + i}: ${l}`).join("\n")
const range = m[2] ? ` lines="${from}-${to}"` : ""
const more = !m[2] && lines.length > to ? `\n(${lines.length - to} more lines — read with offset ${to + 1})` : ""
out.push({ path: rel, text: `<file path="${rel}"${range}>\n${body}${more}\n</file>` })
if (!m[2]) {
ctx.readFiles.add(abs)
ctx.fileStamps.set(abs, st.mtimeMs)
}
}
return out
}
/** Absolute paths for attachments, for frecency. */
export function attachedPaths(atts: Attachment[], root: string): string[] {
return atts.map((a) => a.path.replace(/\/$/, "")).map((p) => (p.startsWith("/") ? p : join(root, p)))
}
+97
View File
@@ -0,0 +1,97 @@
// The project's own records, in .agent/: a task board (tasks.md) and a decision log
// (decisions.md). Plain markdown on purpose — they belong to the project, are read by people and
// committed with it; LLeMbas CLI only edits them.
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs"
import { assertProjectFile } from "./safe.ts"
import { dirname } from "node:path"
import { join } from "node:path"
export const COLUMNS = ["todo", "doing", "done"] as const
export type Column = (typeof COLUMNS)[number]
export interface Task {
id: number
text: string
column: Column
}
const HEAD: Record<Column, string> = { todo: "Todo", doing: "Doing", done: "Done" }
export function readBoard(dir: string): Task[] {
const file = join(dir, "tasks.md")
if (!existsSync(file)) return []
const out: Task[] = []
let col: Column = "todo"
let next = 1
for (const line of readFileSync(file, "utf8").split("\n")) {
const h = /^##\s+(todo|doing|done)\s*$/i.exec(line)
if (h) {
col = h[1]!.toLowerCase() as Column
continue
}
const m = /^\s*-\s+\[[ xX]\]\s+(.*?)\s*(?:\(#(\d+)\))?\s*$/.exec(line)
if (m) {
const id = m[2] ? Number(m[2]) : next
next = Math.max(next, id + 1)
out.push({ id, text: m[1]!, column: col })
}
}
return out
}
export function writeBoard(dir: string, tasks: Task[]) {
mkdirSync(dir, { recursive: true })
const parts = ["# Tasks", ""]
for (const c of COLUMNS) {
parts.push(`## ${HEAD[c]}`, "")
for (const t of tasks.filter((x) => x.column === c)) parts.push(`- [${c === "done" ? "x" : " "}] ${t.text} (#${t.id})`)
parts.push("")
}
assertProjectFile(join(dir, "tasks.md"), dirname(dir))
writeFileSync(join(dir, "tasks.md"), parts.join("\n"))
}
export function renderBoard(tasks: Task[]): string {
if (!tasks.length) return "(the board is empty)"
return COLUMNS.map((c) => {
const rows = tasks.filter((t) => t.column === c)
return `${HEAD[c]} (${rows.length})\n${rows.map((t) => ` #${t.id} ${t.text}`).join("\n") || " —"}`
}).join("\n")
}
/** For the system prompt: what is in progress and what is waiting — never the done pile. */
export function boardSummary(dir: string): string | undefined {
const t = readBoard(dir).filter((x) => x.column !== "done")
if (!t.length) return undefined
const lines = [...t.filter((x) => x.column === "doing"), ...t.filter((x) => x.column === "todo")].slice(0, 15).map((x) => `- #${x.id} [${x.column}] ${x.text}`)
return lines.join("\n")
}
export interface Decision {
date: string
title: string
text: string
}
export function readDecisions(dir: string): Decision[] {
const file = join(dir, "decisions.md")
if (!existsSync(file)) return []
return readFileSync(file, "utf8")
.split(/^## /m)
.slice(1)
.map((block) => {
const [head = "", ...rest] = block.split("\n")
const m = /^(\d{4}-\d{2}-\d{2})\s*·\s*(.*)$/.exec(head.trim())
return { date: m?.[1] ?? "", title: (m?.[2] ?? head).trim(), text: rest.join("\n").trim() }
})
}
export function addDecision(dir: string, title: string, decision: string, why?: string): Decision {
mkdirSync(dir, { recursive: true })
const file = join(dir, "decisions.md")
const date = new Date().toISOString().slice(0, 10)
const body = `**Decision:** ${decision.trim()}${why?.trim() ? `\n\n**Why:** ${why.trim()}` : ""}`
assertProjectFile(file, dirname(dir))
if (!existsSync(file)) writeFileSync(file, "# Decisions\n\nWhat was decided, when, and why — newest last.\n")
appendFileSync(file, `\n## ${date} · ${title.trim()}\n\n${body}\n`)
return { date, title: title.trim(), text: body }
}
+69
View File
@@ -0,0 +1,69 @@
// Custom slash commands: markdown files in ~/.config/lembas/commands/ and (in a trusted
// project) .agent/commands/. The file name is the command; YAML frontmatter may give a
// description, a model and a mode; the body is the prompt. In the body:
// $ARGUMENTS everything after the command $1 … $9 the arguments one by one
// !`command` replaced by that command's output (run in the project, the hardline applies)
// @path attached like any @reference
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { basename, join } from "node:path"
import { parse } from "yaml"
import { paths } from "../config/paths.ts"
import { hardlineCommand, type HardlineRule } from "../permission/hardline.ts"
import { words } from "../permission/bash.ts"
export interface CustomCommand {
name: string
description: string
model?: string
mode?: string
body: string
source: "global" | "project"
file: string
}
function load(dir: string, source: CustomCommand["source"]): CustomCommand[] {
if (!existsSync(dir)) return []
const out: CustomCommand[] = []
for (const f of readdirSync(dir)) {
if (!f.endsWith(".md")) continue
const file = join(dir, f)
const raw = readFileSync(file, "utf8")
const fm = /^---\n([\s\S]*?)\n---\n?/.exec(raw)
let meta: Record<string, unknown> = {}
try {
meta = fm ? ((parse(fm[1]!) as Record<string, unknown>) ?? {}) : {}
} catch {}
const body = fm ? raw.slice(fm[0].length) : raw
out.push({
name: basename(f, ".md").toLowerCase().replace(/[^a-z0-9_-]/g, "-"),
description: String(meta.description ?? body.trim().split("\n")[0]!.slice(0, 60)),
model: typeof meta.model === "string" ? meta.model : undefined,
mode: typeof meta.mode === "string" ? meta.mode : undefined,
body,
source,
file,
})
}
return out
}
/** Global commands, then the project's (a project command overrides a global one of that name). */
export function customCommands(projectDir: string | undefined): CustomCommand[] {
const byName = new Map<string, CustomCommand>()
for (const c of load(join(paths.config, "commands"), "global")) byName.set(c.name, c)
if (projectDir) for (const c of load(join(projectDir, "commands"), "project")) byName.set(c.name, c)
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name))
}
/** The prompt a command sends. */
export function expandCommand(cmd: CustomCommand, args: string, root: string, hardline: HardlineRule[]): string {
const list = words(args)
let text = cmd.body.replaceAll("$ARGUMENTS", args).replace(/\$(\d)/g, (_, n: string) => list[Number(n) - 1] ?? "")
text = text.replace(/!`([^`]+)`/g, (_, command: string) => {
const hit = hardlineCommand(command, hardline)
if (hit) return `[not run — ${hit.description}]`
const r = Bun.spawnSync(["bash", "-c", command], { cwd: root, stdout: "pipe", stderr: "pipe" })
return (r.stdout.toString() + r.stderr.toString()).trim()
})
return text.trim()
}
+103
View File
@@ -0,0 +1,103 @@
// The project's file list for @ completion: ripgrep when present (honours .gitignore), a walk
// otherwise; refreshed at most every few seconds. Ranked with fuzzysort, nudged by frecency.
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import fuzzysort from "fuzzysort"
import { paths } from "../config/paths.ts"
const MAX = 50_000
const IGNORED = ["node_modules", ".git", "dist", "build", ".venv", "venv", "__pycache__", "target", ".next", ".cache"]
const hasRg = Bun.which("rg") !== null
export class FileIndex {
private files: string[] = []
private dirs: string[] = []
private at = 0
private frecency: Record<string, { n: number; t: number }>
constructor(readonly root: string) {
this.frecency = readFrecency()[root] ?? {}
}
private refresh() {
if (Date.now() - this.at < 5000) return
this.at = Date.now()
let list: string[] = []
if (hasRg) {
const r = Bun.spawnSync(["rg", "--files", "--hidden", "--glob", "!.git"], { cwd: this.root, stdout: "pipe", stderr: "ignore" })
list = r.stdout.toString().split("\n").filter(Boolean)
} else {
for (const f of new Bun.Glob("**/*").scanSync({ cwd: this.root, dot: true, onlyFiles: true })) {
if (f.split("/").some((s) => IGNORED.includes(s))) continue
list.push(f)
if (list.length >= MAX) break
}
}
this.files = list.slice(0, MAX).sort()
const dirs = new Set<string>()
for (const f of this.files) {
const parts = f.split("/")
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/") + "/")
}
this.dirs = [...dirs].sort()
}
/** Best matches for what was typed after @. A trailing / lists that directory's children. */
search(query: string, limit = 8): string[] {
this.refresh()
const q = query.replace(/#.*$/, "")
const all = [...this.dirs, ...this.files]
if (!q) return this.recent(limit)
if (q.endsWith("/")) return all.filter((p) => p.startsWith(q) && p !== q && !p.slice(q.length).replace(/\/$/, "").includes("/")).slice(0, limit)
const hits = fuzzysort.go(q, all, { limit: 200, threshold: -10000 })
return hits
.map((h) => ({ p: h.target, score: h.score + this.boost(h.target) - h.target.length / 1000 }))
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((x) => x.p)
}
private recent(limit: number): string[] {
return Object.entries(this.frecency)
.sort((a, b) => b[1].t - a[1].t)
.map(([p]) => p)
.filter((p) => this.files.includes(p))
.slice(0, limit)
}
/** Frecency: used often and lately ranks higher. fuzzysort scores are ≤ 1; this adds up to ~0.3. */
private boost(p: string): number {
const f = this.frecency[p]
if (!f) return 0
const days = (Date.now() - f.t) / 86_400_000
return Math.min(0.3, (Math.log2(f.n + 1) * 0.1) / (1 + days))
}
used(p: string) {
const f = (this.frecency[p] ??= { n: 0, t: 0 })
f.n++
f.t = Date.now()
const all = readFrecency()
all[this.root] = this.frecency
try {
mkdirSync(paths.state, { recursive: true })
writeFileSync(join(paths.state, "frecency.json"), JSON.stringify(all))
} catch {}
}
isDir(p: string): boolean {
try {
return statSync(join(this.root, p)).isDirectory()
} catch {
return false
}
}
}
function readFrecency(): Record<string, Record<string, { n: number; t: number }>> {
try {
return JSON.parse(readFileSync(join(paths.state, "frecency.json"), "utf8"))
} catch {
return {}
}
}
+51
View File
@@ -0,0 +1,51 @@
// Images for vision models: read, size-checked, downscaled when ImageMagick is there.
import { readFileSync, statSync } from "node:fs"
import { extname } from "node:path"
import type { ImagePart } from "../provider/types.ts"
export const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
const MAX_BYTES = 3_500_000
const magick = Bun.which("magick") ?? Bun.which("convert")
export function isImage(path: string): boolean {
return extname(path).toLowerCase() in IMAGE_TYPES
}
/** The image as a part, or a reason it cannot be one. Large ones are shrunk to 1600 px if possible. */
export function loadImage(path: string): ImagePart | string {
const mime = IMAGE_TYPES[extname(path).toLowerCase()]
if (!mime) return `${path} is not a supported image (png, jpg, gif, webp)`
let size: number
try {
size = statSync(path).size
} catch {
return `${path} does not exist`
}
if (size <= MAX_BYTES) return { type: "image", mime, data: readFileSync(path).toString("base64") }
if (!magick) return `${path} is ${(size / 1e6).toFixed(1)} MB — too large to send, and ImageMagick is not installed to shrink it`
// The coder named from the extension: ImageMagick otherwise decides by content, and a file named
// .png that is really SVG or MVG can make it read other files or fetch URLs.
const coder = { ".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".gif": "gif", ".webp": "webp", ".bmp": "bmp" }[extname(path).toLowerCase()]
if (!coder) return `${path}: not shrunk — not a PNG, JPEG, GIF, WebP or BMP file`
const r = Bun.spawnSync([magick, `${coder}:${path}`, "-resize", "1600x1600>", "-quality", "85", "jpeg:-"], { stdout: "pipe", stderr: "pipe" })
if (r.exitCode !== 0 || r.stdout.length === 0) return `${path} is too large, and shrinking it failed: ${r.stderr.toString().trim()}`
if (r.stdout.length > MAX_BYTES) return `${path} is still too large after shrinking`
return { type: "image", mime: "image/jpeg", data: Buffer.from(r.stdout).toString("base64") }
}
/** An image on the clipboard (Wayland or X11), saved to `dest`; undefined when there is none. */
export function clipboardImage(dest: string): string | undefined {
const tries: string[][] = []
if (Bun.which("wl-paste")) tries.push(["wl-paste", "--no-newline", "--type", "image/png"])
if (Bun.which("xclip")) tries.push(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"])
for (const cmd of tries) {
const r = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" })
const out = r.stdout
// A PNG starts with \x89PNG; anything else is text or nothing.
if (r.exitCode === 0 && out.length > 8 && out[0] === 0x89 && out[1] === 0x50) {
Bun.write(dest, out)
return dest
}
}
return undefined
}
+71
View File
@@ -0,0 +1,71 @@
// The first time LLeMbas CLI opens a directory: trust, git, and the .agent skeleton.
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { join, relative } from "node:path"
import { paths } from "../config/paths.ts"
import type { Project } from "./root.ts"
const CONFIG_TEMPLATE = `# LLeMbas CLI settings for this project. Merged over ~/.config/lembas/config.yaml,
# and read only while the project is trusted. Connections cannot be set here.
#
# mode: edit # manual | edit | auto | plan
# model: connection/model
# permission:
# bash:
# "npm test *": allow
# "npm publish *": deny
`
/** Create the project directory (config template, plans/, commands/, skills/, agents/, local/). */
export function initProjectDir(p: Project): string[] {
const made: string[] = []
for (const sub of ["", "plans", "commands", "skills", "agents", "local"]) {
const d = join(p.dir, sub)
if (!existsSync(d)) {
mkdirSync(d, { recursive: true })
made.push(relative(p.root, d) || ".")
}
}
const cfg = join(p.dir, "config.yaml")
if (!existsSync(cfg)) {
writeFileSync(cfg, CONFIG_TEMPLATE)
made.push(relative(p.root, cfg))
}
return made
}
/** Keep `.agent/local/` (per-user state) out of git. Returns true when .gitignore changed. */
export function ignoreLocal(p: Project): boolean {
if (!p.gitRoot) return false
const file = join(p.gitRoot, ".gitignore")
const entry = `/${relative(p.gitRoot, join(p.dir, "local"))}/`
const text = existsSync(file) ? readFileSync(file, "utf8") : ""
if (text.split("\n").some((l) => l.trim() === entry || l.trim() === entry.slice(1))) return false
appendFileSync(file, `${text && !text.endsWith("\n") ? "\n" : ""}# LLeMbas CLI per-user state\n${entry}\n`)
return true
}
// ── per-directory preferences: "never offer git init here" ─────────────────────────────────
function prefsFile() {
return join(paths.state, "projects.json")
}
type Prefs = Record<string, { gitInit?: "never" }>
function readPrefs(): Prefs {
try {
return JSON.parse(readFileSync(prefsFile(), "utf8"))
} catch {
return {}
}
}
export function neverGitInit(root: string): boolean {
return readPrefs()[root]?.gitInit === "never"
}
export function setNeverGitInit(root: string) {
const p = readPrefs()
p[root] = { ...p[root], gitInit: "never" }
mkdirSync(paths.state, { recursive: true })
writeFileSync(prefsFile(), JSON.stringify(p, null, 2))
}
+129
View File
@@ -0,0 +1,129 @@
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
import { parseDocument } from "yaml"
import { paths } from "../config/paths.ts"
import type { Rule } from "../permission/evaluate.ts"
import { assertProjectFile } from "./safe.ts"
export interface Project {
root: string
/** `.agent`, or `.lembas` when `.agent` is already something else's. */
dir: string
gitRoot?: string
exists: boolean
}
const isDir = (p: string) => existsSync(p) && statSync(p).isDirectory()
/** The names the per-project directory goes by: `.agent`, or `.lembas` when `.agent` is
* something else's. */
export const PROJECT_DIR_NAMES = [".agent", ".lembas"] as const
/** What git must not see of any of them: each one's per-user state. */
export const LOCAL_DIRS = PROJECT_DIR_NAMES.map((n) => `${n}/local/`)
// `.agent` is a name other tools use too (`.agent/` for several agents' rules and workflows), so
// it counts as ours only with something of ours in it: the config, plans, the task board or the
// per-user state. `.lembas` is ours by name.
const GENERIC = new Set([".agent"])
const MARKERS = ["config.yaml", "plans", "tasks.md", "local"]
const marked = (dir: string) => MARKERS.some((m) => existsSync(join(dir, m)))
/** A project directory of ours at `root`, under `name`. */
const ours = (root: string, name: string) => isDir(join(root, name)) && (!GENERIC.has(name) || marked(join(root, name)))
const empty = (dir: string) => {
try {
return readdirSync(dir).length === 0
} catch {
return false
}
}
/** The name of the per-project directory under `root`: the one of ours already there, else
* `.agent` — or `.lembas`, when `.agent` is something else's (a file, or a directory with things
* in it but nothing of ours). An empty `.agent` is taken: there is nothing in it to collide with. */
export function projectDirName(root: string): string {
const existing = PROJECT_DIR_NAMES.find((n) => ours(root, n))
if (existing) return existing
const primary = join(root, ".agent")
if (existsSync(primary) && !(isDir(primary) && empty(primary))) return ".lembas"
return ".agent"
}
export function findGitRoot(start: string): string | undefined {
for (let dir = resolve(start); ; dir = dirname(dir)) {
if (existsSync(join(dir, ".git"))) return dir
if (dirname(dir) === dir) return undefined
}
}
/** Nearest ancestor with a project directory; otherwise the git root; otherwise `cwd` itself. */
export function findProject(cwd: string): Project {
const gitRoot = findGitRoot(cwd)
for (let dir = resolve(cwd); ; dir = dirname(dir)) {
// An unmarked `.agent` is passed over, so a `.lembas` beside somebody else's `.agent` is still
// the project's.
for (const name of PROJECT_DIR_NAMES) if (ours(dir, name)) return { root: dir, dir: join(dir, name), gitRoot, exists: true }
if (dir === gitRoot || dirname(dir) === dir) break
}
const root = gitRoot ?? resolve(cwd)
return { root, dir: join(root, projectDirName(root)), gitRoot, exists: false }
}
// ── trust ────────────────────────────────────────────────────────────────────────────────────
// A project's own config, commands and MCP servers are honoured only once its root is trusted.
type TrustLevel = "trusted" | "readonly"
function trustFile() {
return join(paths.state, "trust.json")
}
function readTrust(): Record<string, TrustLevel> {
try {
return JSON.parse(readFileSync(trustFile(), "utf8"))
} catch {
return {}
}
}
export function trustOf(root: string): TrustLevel | undefined {
return readTrust()[resolve(root)]
}
/** Every directory trusted on this machine. */
export function trustedRoots(): string[] {
return Object.entries(readTrust())
.filter(([, level]) => level === "trusted")
.map(([dir]) => dir)
}
/** Whether `dir` is a trusted directory or inside one — what a remote session needs. A git
* repository inside a trusted home is its own project, and its own `.agent` config still needs
* its own trust to be read; but the person who trusted the directory around it has said work may
* happen anywhere in there. */
export function trustedWithin(dir: string): boolean {
const target = resolve(dir)
return trustedRoots().some((t) => target === t || target.startsWith(t.endsWith("/") ? t : `${t}/`))
}
export function setTrust(root: string, level: TrustLevel) {
const t = readTrust()
t[resolve(root)] = level
mkdirSync(paths.state, { recursive: true })
writeFileSync(trustFile(), JSON.stringify(t, null, 2))
}
/** "Always allow (project)": add the rule to the project's config.yaml, keeping its comments. */
export function persistProjectRule(project: Project, rule: Rule) {
const file = join(project.dir, "config.yaml")
mkdirSync(project.dir, { recursive: true })
assertProjectFile(file, project.root)
const doc = parseDocument(existsSync(file) ? readFileSync(file, "utf8") : "")
if (!doc.contents) doc.contents = doc.createNode({}) as never
const current = doc.getIn(["permission", rule.permission])
if (typeof current === "string") {
doc.setIn(["permission", rule.permission], doc.createNode({ "*": current }))
}
doc.setIn(["permission", rule.permission, rule.pattern], rule.action)
writeFileSync(file, doc.toString())
}
+26
View File
@@ -0,0 +1,26 @@
// Files LLeMbas CLI itself writes or reads inside a project (.agent/config.yaml, tasks.md,
// decisions.md, local/MEMORY.md): a cloned repository can commit any of them as a symbolic link,
// and a write that follows one lands wherever it points. Such a file is refused.
import { lstatSync, realpathSync } from "node:fs"
import { dirname } from "node:path"
const inside = (p: string, dir: string) => p === dir || p.startsWith(dir.endsWith("/") ? dir : `${dir}/`)
/** Why `file` (in `root`) may not be used, or undefined when it is an ordinary file — or not there
* yet — in a directory that really is inside the root. */
export function unsafeProjectFile(file: string, root: string): string | undefined {
try {
const st = lstatSync(file)
if (st.isSymbolicLink()) return `${file} is a symbolic link`
if (!st.isFile()) return `${file} is not a regular file`
} catch {}
try {
if (!inside(realpathSync(dirname(file)), realpathSync(root))) return `${dirname(file)} leads out of ${root}`
} catch {}
return undefined
}
export function assertProjectFile(file: string, root: string) {
const why = unsafeProjectFile(file, root)
if (why) throw new Error(`refused: ${why} — LLeMbas CLI does not follow links out of its own files`)
}
+252
View File
@@ -0,0 +1,252 @@
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
import { platform, release } from "node:os"
import { dirname, join, resolve, sep } from "node:path"
import type { Mode } from "../config/schema.ts"
import { expandHome, paths } from "../config/paths.ts"
import defaultSystem from "../../harness/prompts/system/default.md" with { type: "text" }
import identity from "../../harness/prompts/system/identity.md" with { type: "text" }
import blockMemory from "../../harness/prompts/blocks/memory.md" with { type: "text" }
import blockSkills from "../../harness/prompts/blocks/skills.md" with { type: "text" }
import blockSkillsEmpty from "../../harness/prompts/blocks/skills-empty.md" with { type: "text" }
import blockAgentsMissing from "../../harness/prompts/blocks/agents-missing.md" with { type: "text" }
import blockAgentsGit from "../../harness/prompts/blocks/agents-git.md" with { type: "text" }
import blockAgentsKeep from "../../harness/prompts/blocks/agents-keep.md" with { type: "text" }
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
import modeEdit from "../../harness/prompts/modes/edit.md" with { type: "text" }
import modeAuto from "../../harness/prompts/modes/auto.md" with { type: "text" }
import modePlan from "../../harness/prompts/modes/plan.md" with { type: "text" }
import modeUnattended from "../../harness/prompts/modes/unattended.md" with { type: "text" }
import taskCompact from "../../harness/prompts/tasks/compact.md" with { type: "text" }
import taskReview from "../../harness/prompts/tasks/review.md" with { type: "text" }
import taskChangelog from "../../harness/prompts/tasks/changelog.md" with { type: "text" }
import taskContinue from "../../harness/prompts/tasks/continue.md" with { type: "text" }
import taskInit from "../../harness/prompts/tasks/init.md" with { type: "text" }
import familyAnthropic from "../../harness/prompts/family/anthropic.md" with { type: "text" }
import familyGpt from "../../harness/prompts/family/gpt.md" with { type: "text" }
import familyGemini from "../../harness/prompts/family/gemini.md" with { type: "text" }
import familyLocal from "../../harness/prompts/family/local.md" with { type: "text" }
import personalityConcise from "../../harness/prompts/personality/concise.md" with { type: "text" }
import personalityPragmatic from "../../harness/prompts/personality/pragmatic.md" with { type: "text" }
import personalityOptimistic from "../../harness/prompts/personality/optimistic.md" with { type: "text" }
import personalityFunny from "../../harness/prompts/personality/funny.md" with { type: "text" }
import personalityFormal from "../../harness/prompts/personality/formal.md" with { type: "text" }
import personalitySocratic from "../../harness/prompts/personality/socratic.md" with { type: "text" }
// Built-in prompt text, by path relative to harness/prompts (the harness spec shared with LLeMbas).
// Any of these can be replaced by a file at the same path under ~/.config/lembas/prompts/.
const BUILTIN: Record<string, string> = {
"system/default.md": defaultSystem,
"system/identity.md": identity,
"blocks/memory.md": blockMemory,
"blocks/skills.md": blockSkills,
"blocks/skills-empty.md": blockSkillsEmpty,
"blocks/agents-missing.md": blockAgentsMissing,
"blocks/agents-git.md": blockAgentsGit,
"blocks/agents-keep.md": blockAgentsKeep,
"modes/manual.md": modeManual,
"modes/edit.md": modeEdit,
"modes/auto.md": modeAuto,
"modes/plan.md": modePlan,
"modes/unattended.md": modeUnattended,
"tasks/compact.md": taskCompact,
"tasks/review.md": taskReview,
"tasks/continue.md": taskContinue,
"tasks/changelog.md": taskChangelog,
"tasks/init.md": taskInit,
"family/anthropic.md": familyAnthropic,
"family/gpt.md": familyGpt,
"family/gemini.md": familyGemini,
"family/local.md": familyLocal,
"personality/concise.md": personalityConcise,
"personality/pragmatic.md": personalityPragmatic,
"personality/optimistic.md": personalityOptimistic,
"personality/funny.md": personalityFunny,
"personality/formal.md": personalityFormal,
"personality/socratic.md": personalitySocratic,
}
// Paths the harness spec renamed (v1): an override written under the old name still counts.
const RENAMED: Record<string, string> = { "modes/auto.md": "modes/unrestricted.md" }
export function promptText(rel: string): string {
const override = [rel, RENAMED[rel]].filter((r): r is string => r !== undefined).map((r) => join(paths.config, "prompts", r)).find((f) => existsSync(f))
const raw = override ? readFileSync(override, "utf8") : BUILTIN[rel]
if (raw === undefined) throw new Error(`no prompt ${rel}`)
return raw.replace(/<!--[\s\S]*?-->\n?/g, "").trim()
}
export function fill(text: string, vars: Record<string, string>): string {
return text.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (Object.hasOwn(vars, k) ? vars[k]! : m))
}
/** Instruction files: the global AGENTS.md, then the nearest project AGENTS.md or CLAUDE.md
* walking up to the project root (first match wins, as OpenCode does — ancestors do not stack). */
/** An `instructions` entry from config.yaml. A project's may only name files inside the project:
* a cloned repository must not be able to put ~/.ssh/… into what is sent to the model. */
export interface ExtraInstruction {
path: string
global: boolean
}
export function instructionFiles(cwd: string, root: string, extra: ExtraInstruction[] = []): { path: string; text: string }[] {
const out: { path: string; text: string }[] = []
const global = join(paths.config, "AGENTS.md")
if (existsSync(global)) out.push({ path: global, text: readFileSync(global, "utf8") })
let dir = cwd
let realRoot = root
try {
realRoot = realpathSync(root)
} catch {}
for (;;) {
const hit = ["AGENTS.md", "CLAUDE.md"].map((f) => join(dir, f)).find((f) => existsSync(f))
if (hit) {
// Read only when it really is a file in the project: a repository could commit AGENTS.md as
// a link to anything readable, and it would go to the model in every trust state.
try {
const real = realpathSync(hit)
if ((real === realRoot || real.startsWith(realRoot + sep)) && statSync(real).isFile()) out.push({ path: hit, text: readFileSync(real, "utf8") })
} catch {}
break
}
if (dir === root || dirname(dir) === dir) break
dir = dirname(dir)
}
for (const e of extra) {
const path = resolve(root, expandHome(e.path))
if (out.some((f) => f.path === path)) continue
try {
// By where it really is: a link inside the project may point anywhere.
const real = realpathSync(path)
const realRoot = realpathSync(root)
if (!e.global && real !== realRoot && !real.startsWith(realRoot + sep)) continue
out.push({ path, text: readFileSync(real, "utf8") })
} catch {}
}
return out
}
export interface PromptContext {
agentName?: string
modelRef: string
family: string
cwd: string
root: string
/** `instructions` from config.yaml. */
instructions?: ExtraInstruction[]
isGit: boolean
mode: Mode
planDir: string
toolNames: string[]
/** Set when nobody can answer an approval: what will be refused, in words. */
unattended?: string
/** describeRepo(): branch, changes, recent commits. */
git?: string
/** The instruction files (AGENTS.md…) as read at the session's start; read now when absent. */
files?: { path: string; text: string }[]
/** Open items on the project's task board. */
tasks?: string
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions. */
subagent?: { name: string; instructions: string }
/** The memory snapshot taken when the session started ("" when both files are empty);
* undefined when memory is off. */
memory?: string
/** The skills list (skillLines), taken when the session started. */
skills?: string
/** Whether skill_manage is offered (the skills block then says to fix and save skills). */
manageSkills?: boolean
/** The personality (a preset's text, or the person's own), last of all. */
personality?: string
/** The person's custom instructions (config `instructions`), just before the personality. */
userInstructions?: string
/** Connected MCP servers' own instructions (McpManager.instructions()). */
mcp?: string
/** The other configured models, one line each (with their notes). */
roster?: string
}
const MAX_INSTRUCTION_CHARS = 40_000
export function assembleSystem(ctx: PromptContext): string {
const vars = { agent_name: ctx.agentName ?? "LLeMbas", plan_dir: ctx.planDir }
const sections: string[] = []
// Who: the built-in line.
sections.push(fill(promptText("system/identity.md"), vars))
// One shared base, then what this model family needs on top (see the wiki: Prompts-provenance).
// A user may still replace the whole base for a family with prompts/system/<family>.md.
const own = `system/${ctx.family}.md`
sections.push(fill(promptText(existsSync(join(paths.config, "prompts", own)) ? own : "system/default.md"), vars))
const overlay = `family/${ctx.family}.md`
if (BUILTIN[overlay] || existsSync(join(paths.config, "prompts", overlay))) sections.push(fill(promptText(overlay), vars))
sections.push(
[
"<env>",
`Working directory: ${ctx.cwd}`,
`Project root: ${ctx.root}`,
`Git repository: ${ctx.isGit ? "yes" : "no"}`,
`Platform: ${platform()} ${release()}`,
`Today: ${new Date().toISOString().slice(0, 10)}`,
`Model: ${ctx.modelRef}`,
"</env>",
].join("\n"),
)
if (ctx.roster && !ctx.subagent)
sections.push(`Other models configured here — the task tool can run a subagent on one (its model argument); the user can switch to one, and so can you through the settings tool when they ask:\n<models>\n${ctx.roster}\n</models>`)
if (ctx.git) sections.push(`The git repository as it was when this session started — a snapshot, not kept up to date; run git status for how it is now:\n<git>\n${ctx.git}\n</git>`)
if (ctx.tasks && !ctx.subagent) sections.push(`Open items on the project's task board (the tasks tool changes it):\n<project_tasks>\n${ctx.tasks}\n</project_tasks>`)
const files = ctx.files ?? instructionFiles(ctx.cwd, ctx.root, ctx.instructions)
for (const f of files) {
let text = f.text.trim()
if (text.length > MAX_INSTRUCTION_CHARS)
text = text.slice(0, MAX_INSTRUCTION_CHARS) + `\n[…truncated: kept ${MAX_INSTRUCTION_CHARS} of ${f.text.length} characters; read ${f.path} for the rest]`
sections.push(`Instructions from ${f.path}:\n<instructions>\n${text}\n</instructions>`)
}
if (ctx.mcp) sections.push(ctx.mcp)
if (!ctx.subagent) sections.push(agentsBlock(ctx, files))
// Memory and skills: snapshots taken when the session started, so the prompt does not change
// under a session (Hermes' frozen snapshot).
if (ctx.memory !== undefined && !ctx.subagent) {
sections.push(promptText("blocks/memory.md"))
if (ctx.memory) sections.push(ctx.memory)
}
if (ctx.skills)
sections.push(
fill(promptText("blocks/skills.md"), {
skills: ctx.skills,
manage:
ctx.manageSkills && !ctx.subagent
? "Skills are made and changed only with skill_manage, never by writing files. If a skill turns out wrong or incomplete, fix it with skill_manage (patch) before you finish. After a difficult or many-step task, offer to save what you worked out as a skill."
: "",
}).replace(/\n{3,}/g, "\n\n"),
)
else if (ctx.manageSkills && !ctx.subagent) sections.push(promptText("blocks/skills-empty.md"))
if (ctx.subagent)
sections.push(
`You are the "${ctx.subagent.name}" subagent. You work for the main agent, not for the user directly, on one task it gave you; you cannot ask anyone anything. ${ctx.subagent.instructions}\n\nOnly your final reply reaches the main agent — make it complete on its own: the result, then the evidence (file:line), then anything you could not settle.${ctx.mode === "plan" ? " You can read and search; you cannot change anything." : ""}`,
)
else sections.push(fill(promptText(`modes/${ctx.mode}.md`), vars))
if (ctx.unattended) sections.push(fill(promptText("modes/unattended.md"), { ...vars, refused: ctx.unattended }))
// What the person said about themselves goes last, under the web UI's headings and in its order.
// Not for a subagent: it answers the main agent, not the person.
if (ctx.userInstructions && !ctx.subagent) sections.push(`## How the person you are talking to wants to be helped\n\n${ctx.userInstructions}`)
if (ctx.personality && !ctx.subagent) sections.push(`## Personality\n\n${ctx.personality}`)
return sections.join("\n\n")
}
const GIT_QUESTION =
"do you commit on your own once a change is finished and checked, only when they ask, or never; and which branch the work goes on (the current one, or a new branch for each task)."
const GIT_QUESTION_NO_REPO = "this folder is not a git repository yet — should it be one, and if so, do you commit on your own once a change is finished and checked, or only when they ask."
/** The project's own AGENTS.md (or CLAUDE.md): make it when missing, settle git when it does not,
* and keep it current. The global ~/.config/lembas/AGENTS.md is not the project's. */
function agentsBlock(ctx: PromptContext, files: { path: string; text: string }[]): string {
const own = files.find((f) => f.path !== join(paths.config, "AGENTS.md"))
const vars = { root: ctx.root, git_question: ctx.isGit ? GIT_QUESTION : GIT_QUESTION_NO_REPO }
if (!own) return fill(promptText("blocks/agents-missing.md"), vars)
const keep = promptText("blocks/agents-keep.md")
return /^#{1,3}\s*git\b/im.test(own.text) ? keep : `${fill(promptText("blocks/agents-git.md"), vars)}\n\n${keep}`
}
+25
View File
@@ -0,0 +1,25 @@
// How the agent talks, and how the person wants to be helped: the two things a person says about
// themselves, appended last to the system prompt — the same keys, presets, headings and order as
// the LLeMbas web UI, so one account reads the same in either place.
//
// Not Hermes Agent's system of named personalities (pirate, noir, kawaii…) and a SOUL.md that
// replaces the identity line: a preset is a short instruction about manner, not a character to
// play, and the six below are
// harness texts (harness/prompts/personality/), shared with the web UI word for word.
import type { Config } from "../config/schema.ts"
import { PERSONALITIES, PERSONALITY_PRESETS } from "../config/schema.ts"
import { promptText } from "./assemble.ts"
export { PERSONALITIES, PERSONALITY_PRESETS }
/** A preset's text; custom is the person's own (personality_custom); none or unknown: undefined. */
export function personalityText(name: string | undefined, custom?: string): string | undefined {
if (!name) return undefined
if (name === "custom") return custom?.trim() || undefined
return (PERSONALITY_PRESETS as readonly string[]).includes(name) ? promptText(`personality/${name}.md`) : undefined
}
/** What the system prompt carries for these settings: the custom instructions, the personality. */
export function personalityOf(config: Pick<Config, "personality" | "personality_custom" | "instructions">): { personality?: string; userInstructions?: string } {
return { personality: personalityText(config.personality, config.personality_custom), userInstructions: config.instructions?.trim() || undefined }
}
+8
View File
@@ -0,0 +1,8 @@
declare module "*.md" {
const text: string
export default text
}
declare module "*/VERSION" {
const text: string
export default text
}
+228
View File
@@ -0,0 +1,228 @@
// Anthropic Messages (`POST {base}/messages`) — api.anthropic.com, and servers that imitate it
// (vLLM, llama-server). Content blocks both ways; thinking with signatures that must be sent back
// on the next turn; optional prompt-caching breakpoints.
import type { Effort } from "../config/schema.ts"
import { resolveKey } from "../config/load.ts"
import { Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
import { sseJson } from "./sse.ts"
import { estimateTokens } from "./tokens.ts"
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
/** Thinking budgets by effort, when the model's config gives no effort_map. */
export const DEFAULT_BUDGETS: Record<Effort, number> = { minimal: 1024, low: 2048, medium: 8192, high: 16384, xhigh: 32000, max: 48000 }
type Block = Record<string, unknown>
type AMessage = { role: "user" | "assistant"; content: Block[] }
/** Anthropic ids must match ^[a-zA-Z0-9_-]+$; ids from other providers are mapped consistently. */
export const safeId = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") || "call"
export function toAnthropicMessages(messages: Message[], vision: boolean, thinking: boolean): AMessage[] {
const out: AMessage[] = []
const push = (role: AMessage["role"], blocks: Block[]) => {
if (!blocks.length) return
const last = out[out.length - 1]
// Consecutive turns of one role are merged: tool results and a following note are one user turn.
if (last && last.role === role) last.content.push(...blocks)
else out.push({ role, content: blocks })
}
for (const m of messages) {
if (m.role === "user") {
push(
"user",
m.parts.map((p) =>
p.type === "text"
? { type: "text", text: p.text }
: vision
? { type: "image", source: { type: "base64", media_type: p.mime, data: p.data } }
: { type: "text", text: "[image omitted: this model has no vision]" },
),
)
} else if (m.role === "assistant") {
const blocks: Block[] = []
for (const p of m.parts) {
if (p.type === "reasoning") {
// A thinking block can only go back with its signature, and only while thinking is on.
if (!thinking) continue
const redacted = (p.opaque as { redacted?: string } | undefined)?.redacted
if (redacted) blocks.push({ type: "redacted_thinking", data: redacted })
else if (p.signature) blocks.push({ type: "thinking", thinking: p.text, signature: p.signature })
} else if (p.type === "text") {
if (p.text.trim()) blocks.push({ type: "text", text: p.text })
} else {
let input: unknown = {}
try {
input = p.args.trim() ? JSON.parse(p.args) : {}
} catch {
input = { _unparsed: p.args }
}
blocks.push({ type: "tool_use", id: safeId(p.id), name: p.name, input })
}
}
push("assistant", blocks)
} else {
push("user", [{ type: "tool_result", tool_use_id: safeId(m.callId), content: m.content || "(empty)", ...(m.isError ? { is_error: true } : {}) }])
}
}
return out
}
export class AnthropicClient implements Client {
constructor(private m: ResolvedModel) {}
private headers() {
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), { "anthropic-version": "2023-06-01", ...this.m.spec.headers })
}
buildBody(req: ChatRequest): Record<string, unknown> {
const { m } = this
const budget = req.effort ? (m.spec.effort_map?.[req.effort] ?? DEFAULT_BUDGETS[req.effort]) : undefined
let maxTokens = m.spec.max_output ?? 8192
if (budget && maxTokens <= budget) maxTokens = budget + Math.min(8192, maxTokens)
const cache = m.spec.cache === true
const body: Record<string, unknown> = {
model: m.id,
max_tokens: maxTokens,
stream: true,
messages: toAnthropicMessages(req.messages, m.spec.vision === true, !!budget),
...m.connection.body,
...m.spec.body,
}
if (req.system) body.system = cache ? [{ type: "text", text: req.system, cache_control: { type: "ephemeral" } }] : req.system
if (req.tools.length && m.spec.tools !== false) {
const tools = req.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters }))
if (cache) (tools[tools.length - 1] as Block).cache_control = { type: "ephemeral" }
body.tools = tools
}
if (cache) {
const msgs = body.messages as AMessage[]
const last = msgs[msgs.length - 1]?.content
if (last?.length) last[last.length - 1] = { ...last[last.length - 1], cache_control: { type: "ephemeral" } }
}
if (budget) body.thinking = { type: "enabled", budget_tokens: budget }
else {
// Sampling parameters and extended thinking do not mix on this API.
if (m.spec.temperature !== undefined) body.temperature = m.spec.temperature
if (m.spec.top_p !== undefined) body.top_p = m.spec.top_p
}
return body
}
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
const body = this.buildBody(req)
yield* retrying(() => this.attempt(req, body))
}
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
const { m } = this
const res = await request(
joinUrl(m.connection.base_url, "messages"),
{ method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) },
m.ref,
).catch((e) => {
recordFailure(m.ref, body, e)
throw e
})
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
const out = new Parts()
const banner = new SwapBanner()
type Open = { type: string; text: string; signature?: string; id?: string; name?: string; json: string; data?: string; index: number }
const blocks = new Map<number, Open>()
let toolIndex = 0
let finish = "stop"
let input = 0
let output = 0
let cached: number | undefined
let sawUsage = false
for await (const { data } of sseJson(record(res.body, m.ref, body))) {
const ev = data as any
switch (ev?.type) {
case "message_start": {
const u = ev.message?.usage
if (u) {
sawUsage = true
cached = u.cache_read_input_tokens || undefined
input = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
output = u.output_tokens ?? 0
}
break
}
case "content_block_start": {
const b = ev.content_block ?? {}
blocks.set(ev.index, { type: b.type, text: b.text ?? b.thinking ?? "", id: b.id, name: b.name, json: "", data: b.data, index: ev.index })
if (b.type === "tool_use") yield { type: "tool_call_delta", index: toolIndex, name: b.name, argsDelta: "" }
if (b.type === "text" && b.text) {
out.add("text", b.text)
yield { type: "text", text: b.text }
}
break
}
case "content_block_delta": {
const blk = blocks.get(ev.index)
const d = ev.delta ?? {}
if (!blk) break
if (d.type === "text_delta") {
out.add("text", d.text)
yield { type: "text", text: d.text }
} else if (d.type === "thinking_delta") {
const b = banner.feed(d.thinking ?? "")
if (b.notice) yield { type: "notice", message: b.notice }
if (b.text) {
blk.text += b.text
if (b.text.trim()) out.output = true
yield { type: "reasoning", text: b.text }
}
} else if (d.type === "signature_delta") blk.signature = (blk.signature ?? "") + d.signature
else if (d.type === "input_json_delta") {
blk.json += d.partial_json ?? ""
out.output = true
yield { type: "tool_call_delta", index: toolIndex, argsDelta: d.partial_json ?? "" }
}
break
}
case "content_block_stop": {
const blk = blocks.get(ev.index)
if (!blk) break
if (blk.type === "thinking" && blk.text) out.push({ type: "reasoning", text: blk.text, signature: blk.signature })
else if (blk.type === "redacted_thinking") out.push({ type: "reasoning", text: "", opaque: { redacted: blk.data } })
else if (blk.type === "tool_use") {
const call: ToolCallPart = { type: "tool_call", id: blk.id || `toolu_${toolIndex}`, name: blk.name ?? "", args: blk.json || "{}" }
out.push(call)
toolIndex++
}
blocks.delete(ev.index)
break
}
case "message_delta": {
if (ev.delta?.stop_reason) finish = ev.delta.stop_reason
if (ev.usage?.output_tokens !== undefined) {
sawUsage = true
output = ev.usage.output_tokens
}
break
}
case "error": {
const err = ev.error ?? {}
throw new ProviderError(`${m.ref}: ${err.message ?? JSON.stringify(err)}`, statusOf(err) ?? (err.type === "overloaded_error" ? 529 : undefined), undefined, !out.output)
}
default:
if (ev?.error) throw new ProviderError(`${m.ref}: ${ev.error.message ?? JSON.stringify(ev.error)}`, statusOf(ev.error), undefined, !out.output)
}
}
const message = out.message()
const reason = finish === "max_tokens" ? "length" : finish === "tool_use" || message.parts.some((p) => p.type === "tool_call") ? "tool_calls" : "stop"
yield sawUsage
? { type: "usage", usage: { input, output, cached } }
: { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
yield { type: "finish", reason, message }
}
async listModels(): Promise<DiscoveredModel[]> {
const res = await request(joinUrl(this.m.connection.base_url, "models"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
const j = (await res.json()) as any
return (Array.isArray(j?.data) ? j.data : []).map((x: any) => ({ id: String(x.id), context: x.context_window ?? x.max_input_tokens })).filter((x: DiscoveredModel) => x.id)
}
}
+160
View File
@@ -0,0 +1,160 @@
// What every dialect shares: the retry rule, the part builder, the stream recorder, and
// llama-swap's loading banner.
import type { ReasoningPart, StreamEvent, TextPart, ToolCallPart, Message } from "./types.ts"
import { ProviderError } from "./types.ts"
import { env } from "../config/paths.ts"
type Assistant = Extract<Message, { role: "assistant" }>
/** Builds an assistant message as deltas arrive, and knows whether anything real has been said —
* the line after which a retry would duplicate output. */
export class Parts {
readonly parts: Assistant["parts"] = []
output = false
add(kind: "text" | "reasoning", text: string) {
if (!text) return
if (text.trim()) this.output = true
const last = this.parts[this.parts.length - 1]
if (last && last.type === kind && !(last as ReasoningPart).signature && !(last as ReasoningPart).opaque) (last as TextPart | ReasoningPart).text += text
else this.parts.push({ type: kind, text } as TextPart | ReasoningPart)
}
push(p: ReasoningPart | ToolCallPart) {
this.output = true
this.parts.push(p)
}
message(): Assistant {
return { role: "assistant", parts: this.parts }
}
}
/** One decision per failed attempt: retry (optionally saying why), or give up. */
export type RetryHandler = (e: ProviderError) => { retry: boolean; notice?: string }
/** Run attempts until one succeeds. A retry is only ever considered while nothing of the reply has
* been passed on (`beforeOutput`); a server error gets two retries on top of whatever `handle` allows. */
export async function* retrying(attempt: () => AsyncGenerator<StreamEvent>, handle?: RetryHandler): AsyncGenerator<StreamEvent> {
let retried5xx = 0
for (let n = 0; ; n++) {
try {
yield* attempt()
return
} catch (e) {
if (!(e instanceof ProviderError) || !e.beforeOutput || n >= 3) throw e
const h = handle?.(e)
if (h?.retry) {
if (h.notice) yield { type: "notice", message: h.notice }
continue
}
// Often the model's own output failing the server's parser (llama.cpp + gpt-oss), or the
// model swapped out under us (llama-swap): sampling differs on a second go.
// Twice: the first at once, the second after a pause (a model still loading, a busy server).
if ((e.status ?? 0) >= 500 && retried5xx < 2) {
retried5xx++
yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${e.message.split("\n")[0]!.slice(0, 160)}` }
if (retried5xx === 2) await new Promise((r) => setTimeout(r, 2000))
continue
}
throw e
}
}
}
/** Status for an error object inside a stream: a numeric code is HTTP; llama-swap's
* `type: "server_error"` / `code: "internal_error"` is a 500 too. */
export function statusOf(err: any): number | undefined {
if (typeof err?.code === "number") return err.code
if (typeof err?.status === "number") return err.status
if (err?.type === "server_error" || err?.type === "overloaded_error" || err?.type === "api_error" || /internal/i.test(String(err?.code ?? ""))) return 500
return undefined
}
export function recordName(ref: string) {
return `${env("RECORD")}/${new Date().toISOString().replace(/[:.]/g, "-")}-${ref.replace(/[^\w.-]+/g, "_")}`
}
export function recordFailure(ref: string, request: unknown, e: unknown) {
if (!env("RECORD")) return
const name = recordName(ref)
void Bun.write(`${name}.request.json`, JSON.stringify(request, null, 2))
const err = e as ProviderError
void Bun.write(`${name}.error.json`, JSON.stringify({ status: err.status, message: err.message, body: err.body }, null, 2))
}
/** LEMBAS_RECORD=<dir>: keep each request body and its raw SSE, to become test fixtures. */
export function record(body: ReadableStream<Uint8Array>, ref: string, request: unknown): ReadableStream<Uint8Array> {
const dir = env("RECORD")
if (!dir) return body
const [a, b] = body.tee()
const name = recordName(ref)
void Bun.write(`${name}.request.json`, JSON.stringify(request, null, 2))
void new Response(b).text().then((raw) => Bun.write(`${name}.sse`, raw))
return a
}
/** llama-swap streams its model-loading progress as reasoning, fenced by "━━━━━" lines, before the
* model's own first token. That is not the model thinking: it becomes one notice. */
export class SwapBanner {
private buf = ""
private state: "start" | "inside" | "done" = "start"
private skipSpace = false
feed(text: string): { text: string; notice?: string } {
if (this.state === "done") {
// The banner is followed by blank lines, streamed token by token; they are its, not the model's.
if (this.skipSpace) {
text = text.trimStart()
if (text) this.skipSpace = false
}
return { text }
}
this.buf += text
if (this.state === "start") {
const probe = this.buf.trimStart()
if (probe.length < 5 && "━━━━━".startsWith(probe)) return { text: "" }
if (!probe.startsWith("━━━━━")) {
this.state = "done"
const out = this.buf
this.buf = ""
return { text: out }
}
this.state = "inside"
}
const open = this.buf.indexOf("━━━━━")
const close = this.buf.indexOf("━━━━━", open + 5)
if (close === -1) {
if (this.buf.length > 4000) {
this.state = "done"
const out = this.buf
this.buf = ""
return { text: out }
}
return { text: "" }
}
const inner = this.buf.slice(open + 5, close).replace(/━+/g, "").trim()
let rest = this.buf.slice(close).replace(/^━+\s*/, "")
this.state = "done"
this.buf = ""
rest = rest.trimStart()
this.skipSpace = rest === ""
const lines = inner.split("\n").map((l) => l.trim()).filter(Boolean)
const done = lines.find((l) => /^Done!/.test(l)) ?? ""
const what = lines[0] ?? "model loading"
return { text: rest, notice: `${what}${done ? " — " + done.toLowerCase() : ""}` }
}
}
/** A tool call's arguments as they go back to the server in the history. A call cut off mid-way
* (the output limit) or garbled by the model has arguments that are not JSON, and llama.cpp parses
* the history's arguments again on every request — one broken call there fails each request after
* it with a 500. The tool result beside it already told the model what went wrong. */
export function historyArgs(args: string): string {
try {
const v = JSON.parse(args)
if (v && typeof v === "object" && !Array.isArray(v)) return args
} catch {}
return "{}"
}
+53
View File
@@ -0,0 +1,53 @@
// A model's context window when the config does not give one. Config always wins; what is found
// here is kept in learned.json. Tried in order: the server's model list; llama-server's /props
// (directly, or through llama-swap's /upstream/<model>/ passthrough); Ollama's /api/show.
import { learned, learnContext } from "./learned.ts"
import { authHeaders, request, tlsFor } from "./http.ts"
import { OllamaClient } from "./ollama.ts"
import type { Client, ResolvedModel } from "./types.ts"
import { resolveKey } from "../config/load.ts"
/** The window to plan with: configured, else learned, else undefined (unknown). */
export function contextWindow(m: ResolvedModel): number | undefined {
return m.spec.context || learned().contexts[m.ref] || undefined
}
export async function discoverContext(m: ResolvedModel, client: Client): Promise<number | undefined> {
if (m.spec.context) return m.spec.context
const known = learned().contexts[m.ref]
if (known) return known
const found = (await fromList(m, client)) ?? (await fromProps(m)) ?? (m.connection.dialect === "ollama" ? await (client as OllamaClient).contextOf(m.id).catch(() => undefined) : undefined)
if (found && found > 0) learnContext(m.ref, found)
return found
}
async function fromList(m: ResolvedModel, client: Client): Promise<number | undefined> {
try {
return (await client.listModels()).find((d) => d.id === m.id)?.context
} catch {
return undefined
}
}
async function fromProps(m: ResolvedModel): Promise<number | undefined> {
const base = m.connection.base_url.replace(/\/+$/, "")
const origin = base.replace(/\/v1$/, "")
const headers = authHeaders(m.connection, resolveKey(m.connectionName, m.connection))
for (const url of [`${origin}/upstream/${encodeURIComponent(m.id)}/props`, `${origin}/props`]) {
try {
const res = await request(url, { headers, timeoutMs: 15_000, tls: tlsFor(m.connection) }, m.ref)
const j = (await res.json()) as any
const n = Number(j?.default_generation_settings?.n_ctx ?? j?.n_ctx)
if (Number.isFinite(n) && n > 0) return n
} catch {}
}
return undefined
}
/** llama-swap and friends: free the connection's memory when the session moves elsewhere. */
export async function unload(m: ResolvedModel): Promise<void> {
if (!m.connection.unload_url) return
try {
await request(m.connection.unload_url, { method: m.connection.unload_method ?? "POST", headers: authHeaders(m.connection, resolveKey(m.connectionName, m.connection)), timeoutMs: 10_000, tls: tlsFor(m.connection) }, m.ref)
} catch {}
}
+79
View File
@@ -0,0 +1,79 @@
// Reasoning effort. Logic ported from LLeMbas services/chat.py and generation.py
// (© Jaroslav Beneš, MIT), where the reasons are written up at length:
// - llama.cpp silently drops a top-level `reasoning_effort`; what reaches a gpt-oss behind it is
// `chat_template_kwargs.reasoning_effort`, so by default both are sent — and only when chosen.
// - A template that does not know a value raises and fails the whole request (Bonsai refuses
// `high`), so a refusal is detected, the model's vocabulary narrowed, and the request retried once.
import { EFFORTS, type Effort } from "../config/schema.ts"
import type { ResolvedModel } from "./types.ts"
import { learned } from "./learned.ts"
export const DEFAULT_EFFORTS: Effort[] = ["low", "medium", "high"]
/** The efforts this model accepts, in offering order: learned > configured > the common three. */
export function effortsFor(m: ResolvedModel): Effort[] {
const list = learned().efforts[m.ref] ?? m.spec.efforts ?? DEFAULT_EFFORTS
return EFFORTS.filter((e) => list.includes(e))
}
/** The model's default effort, or null for off / not a reasoning model. */
export function defaultEffort(m: ResolvedModel): Effort | null {
const e = m.spec.effort
if (!e || e === "off") return null
return effortsFor(m).includes(e) ? e : null
}
/** Put the effort into an openai-chat request body. An effort outside the vocabulary is dropped. */
export function applyEffort(body: Record<string, unknown>, m: ResolvedModel, effort: Effort | null | undefined) {
if (!effort || !effortsFor(m).includes(effort)) return
const style = m.spec.effort_style ?? "both"
if (style !== "kwargs") body.reasoning_effort = effort
if (style !== "top") {
const kwargs = { ...((body.chat_template_kwargs as Record<string, unknown>) ?? {}) }
kwargs.reasoning_effort = effort
body.chat_template_kwargs = kwargs
}
}
export function stripEffort(body: Record<string, unknown>): Record<string, unknown> {
const out = { ...body }
delete out.reasoning_effort
const kwargs = { ...((out.chat_template_kwargs as Record<string, unknown>) ?? {}) }
delete kwargs.reasoning_effort
if (Object.keys(kwargs).length) out.chat_template_kwargs = kwargs
else delete out.chat_template_kwargs
return out
}
/** Whether an error is a chat template refusing the effort. Deliberately narrow. */
export function effortRefused(message: string): boolean {
const m = message.toLowerCase()
return m.includes("effort") && (m.includes("unexpected") || m.includes("supported"))
}
/** Efforts an error message advertises ("Supported types are xhigh (default), medium, and low"). */
export function advertisedEfforts(message: string): Effort[] {
const m = message.toLowerCase()
const at = m.indexOf("supported")
if (at === -1) return []
// Whole words: "high" is a substring of "xhigh".
const words = new Set(m.slice(at).match(/[a-z]+/g) ?? [])
return EFFORTS.filter((e) => words.has(e))
}
/** Read the accepted efforts out of a Jinja chat template (llama-server /props). ≥2 or nothing. */
export function effortsFromTemplate(template: string): Effort[] {
if (!template || !template.includes("reasoning_effort")) return []
const found = new Set<string>()
for (const match of template.matchAll(/reasoning_effort/g)) {
let window = template.slice(match.index, match.index + 400)
if (window.includes("%}")) window = window.split("%}")[0]!
for (const lit of window.matchAll(/['"]([a-z]{3,8})['"]/g)) found.add(lit[1]!)
}
for (const group of template.matchAll(/[[(]((?:\s*['"][a-z]{3,8}['"]\s*,?)+)[\])]/g)) {
const lits = [...group[1]!.matchAll(/['"]([a-z]{3,8})['"]/g)].map((x) => x[1]!)
if (lits.length >= 2 && lits.every((v) => (EFFORTS as readonly string[]).includes(v))) lits.forEach((v) => found.add(v))
}
const out = EFFORTS.filter((e) => found.has(e))
return out.length >= 2 ? out : []
}
+316
View File
@@ -0,0 +1,316 @@
// Google Gemini, native (`{base}/models/{id}:streamGenerateContent?alt=sse`), base
// https://generativelanguage.googleapis.com/v1beta. UNTESTED: no Gemini endpoint has been
// available to try it against (see the wiki's Working-notes).
import type { Effort } from "../config/schema.ts"
import { resolveKey } from "../config/load.ts"
import { DEFAULT_BUDGETS } from "./anthropic.ts"
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
import { sseJson } from "./sse.ts"
import { estimateTokens } from "./tokens.ts"
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent, type ToolCallPart } from "./types.ts"
type Content = { role: "user" | "model"; parts: Record<string, unknown>[] }
// Checked against Hermes Agent (agent/gemini_native_adapter.py, agent/gemini_schema.py) and
// OpenCode (provider/transform.ts). Still untested against a real Gemini endpoint.
/** Gemini's own id, without an aggregator prefix. */
const bare = (id: string) => id.replace(/^(models|google|gemini)\//i, "")
/** Gemini 3 and later: tool calls and results carry ids, and thinking is a level, not a budget. */
export const isGemini3 = (id: string) => Number(/gemini-(\d+)/i.exec(bare(id))?.[1] ?? 0) >= 3
const LEGACY = /gemini-(?:(?:flash|pro)-)?[12](?:[.-]|$)/i
/** A call replayed without a signature (made by another model, or before signatures existed):
* Google's documented sentinel, without which Gemini 3 refuses the whole history. */
export const SKIP_SIGNATURE = "skip_thought_signature_validator"
/** Thought tokens count against maxOutputTokens, and Gemini's own default is low. */
export const DEFAULT_MAX_OUTPUT = 65535
const PLACEHOLDER = "[The previous response was interrupted before it completed.]"
// ── tool schemas ──────────────────────────────────────────────────────────────────────────────
// v1beta takes plain JSON Schema as `parametersJsonSchema`; only same-document $refs have to be
// inlined. Other API versions take `parameters`, an OpenAPI subset where one unknown key 400s the
// whole request, so that path drops everything outside the subset.
const ALLOWED = new Set(["type", "format", "title", "description", "nullable", "enum", "maxItems", "minItems", "properties", "required", "minProperties", "maxProperties", "minLength", "maxLength", "pattern", "example", "anyOf", "propertyOrdering", "default", "items", "minimum", "maximum"])
const STRUCTURAL: Record<string, string[]> = { array: ["items", "minItems", "maxItems"], object: ["properties", "required", "minProperties", "maxProperties", "propertyOrdering"] }
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v)
export function geminiSchema(s: unknown): Record<string, unknown> {
if (!isObj(s)) return {}
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(s)) {
if (!ALLOWED.has(k)) continue
if (k === "properties") {
if (isObj(v)) out.properties = Object.fromEntries(Object.entries(v).map(([p, sub]) => [p, geminiSchema(sub)]))
} else if (k === "items") out.items = geminiSchema(v)
else if (k === "anyOf") {
if (Array.isArray(v)) out.anyOf = v.filter(isObj).map(geminiSchema)
} else out[k] = v
}
const types = out.type
if (Array.isArray(types)) {
delete out.type
const real = types.filter((t): t is string => typeof t === "string" && t !== "null")
if (!real.length) out.type = types.includes("null") ? "null" : "object"
else if (real.length === 1) out.type = real[0]
else {
// Several types: one branch each, and the structure belongs on the branch it describes.
const moved: Record<string, unknown> = {}
for (const keys of Object.values(STRUCTURAL)) for (const k of keys) if (k in out) (moved[k] = out[k]), delete out[k]
out.anyOf = real.map((t) => geminiSchema({ type: t, ...Object.fromEntries(Object.entries(moved).filter(([k]) => STRUCTURAL[t]?.includes(k))) }))
}
if (types.includes("null")) out.nullable = true
}
// Every enum entry must be a string, whatever the declared type.
if (Array.isArray(out.enum) && (Array.isArray(types) || ["integer", "number", "boolean"].includes(out.type as string))) {
const vals = [...new Set(out.enum.map((e) => (typeof e === "boolean" || (typeof e === "number" && Number.isFinite(e)) ? String(e) : typeof e === "string" ? e : undefined)).filter((e) => e !== undefined))]
if (vals.length) out.enum = vals
else delete out.enum
}
// `required` naming a property that is not there fails the entire request.
if (Array.isArray(out.required)) {
const names = isObj(out.properties) ? out.properties : {}
const req = out.required.filter((r): r is string => typeof r === "string" && r in names)
if (req.length) out.required = req
else delete out.required
}
return out
}
/** For `parameters`: the subset, and never an empty schema. */
export function geminiParameters(s: unknown): Record<string, unknown> {
const out = geminiSchema(s)
return Object.keys(out).length ? out : { type: "object", properties: {} }
}
function inlineRefs(node: unknown, root: Record<string, unknown>, budget: { n: number }, stack: string[] = []): unknown {
if (Array.isArray(node)) return node.map((x) => inlineRefs(x, root, budget, stack))
if (!isObj(node)) return node
const ref = node.$ref
if (typeof ref !== "string") return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, inlineRefs(v, root, budget, stack)]))
if (stack.includes(ref)) throw new Error(`circular $ref ${ref}`)
if (--budget.n < 0) throw new Error("$ref budget exhausted")
if (!ref.startsWith("#/")) throw new Error(`unresolvable $ref ${ref}`)
let target: unknown = root
for (const raw of ref.slice(2).split("/")) {
const part = raw.replace(/~1/g, "/").replace(/~0/g, "~")
target = isObj(target) ? target[part] : undefined
}
if (!isObj(target)) throw new Error(`unresolvable $ref ${ref}`)
const inlined = inlineRefs(target, root, budget, [...stack, ref]) as Record<string, unknown>
// Siblings of a $ref apply alongside it and win.
const { $ref: _, ...siblings } = node
return Object.keys(siblings).length ? { ...inlined, ...(inlineRefs(siblings, root, budget, stack) as object) } : inlined
}
/** For `parametersJsonSchema`: root $schema dropped, local $refs inlined, an object root. A schema
* whose refs cannot all be resolved goes as it is, so the provider names the real problem. */
export function geminiJsonSchema(s: unknown): Record<string, unknown> {
if (!isObj(s) || !Object.keys(s).length) return { type: "object", properties: {} }
const { $schema: _, ...rest } = structuredClone(s)
let out: Record<string, unknown>
try {
out = inlineRefs(rest, rest, { n: 256 }) as Record<string, unknown>
} catch {
return rest
}
delete out.$defs
delete out.definitions
if (!Object.keys(out).length) return { type: "object", properties: {} }
if (out.type === "object" && !("properties" in out)) out.properties = {}
return out
}
/** `parametersJsonSchema` exists only on v1beta. */
export const acceptsJsonSchema = (base: string) => /\/v1beta\/*$/i.test(base.trim())
// ── history ───────────────────────────────────────────────────────────────────────────────────
/** A JSON Schema inside a function response is resolved by Gemini 3, and an unknown $ref 400s. */
const hasRef = (v: unknown): boolean => (Array.isArray(v) ? v.some(hasRef) : isObj(v) ? Object.entries(v).some(([k, x]) => (k === "$ref" && typeof x === "string" && x.startsWith("#/")) || hasRef(x)) : false)
function functionResponse(content: string, isError: boolean | undefined): Record<string, unknown> {
if (isError) return { error: content }
// A JSON object result goes structured; anything else under "output", as Google documents.
if (/^\s*\{/.test(content))
try {
const parsed = JSON.parse(content)
if (isObj(parsed) && !hasRef(parsed)) return parsed
} catch {}
return { output: content }
}
export function toGeminiContents(messages: Message[], vision: boolean, ids = false): Content[] {
const out: Content[] = []
const names = new Map<string, string>()
const results = (c: Content) => c.parts.some((p) => "functionResponse" in p)
const push = (role: Content["role"], parts: Record<string, unknown>[]) => {
if (!parts.length) return
const last = out[out.length - 1]
// Same-role turns must merge (Gemini insists on alternation), except that a person's words are
// never folded into a turn of tool results: Gemini 3 then reads them as part of the result and
// answers with nothing. A placeholder model turn keeps the two apart.
if (last && last.role === role && role === "user" && results(last) !== parts.some((p) => "functionResponse" in p)) out.push({ role: "model", parts: [{ text: PLACEHOLDER }] }, { role, parts })
else if (last && last.role === role) last.parts.push(...parts)
else out.push({ role, parts })
}
for (const m of messages) {
if (m.role === "user")
push(
"user",
m.parts.map((p) => (p.type === "text" ? { text: p.text } : vision ? { inlineData: { mimeType: p.mime, data: p.data } } : { text: "[image omitted: this model has no vision]" })),
)
else if (m.role === "assistant") {
const parts: Record<string, unknown>[] = []
for (const p of m.parts) {
if (p.type === "text" && p.text.trim()) parts.push({ text: p.text })
else if (p.type === "tool_call") {
names.set(p.id, p.name)
let args: unknown = {}
try {
args = p.args.trim() ? JSON.parse(p.args) : {}
} catch {
args = { _raw: p.args }
}
// Gemini 3 wants the thought signature back on the call it came with.
parts.push({ functionCall: { name: p.name, args: isObj(args) ? args : { _value: args }, ...(ids ? { id: p.id } : {}) }, thoughtSignature: p.signature ?? SKIP_SIGNATURE })
}
}
push("model", parts)
} else push("user", [{ functionResponse: { name: names.get(m.callId) ?? m.name, response: functionResponse(m.content, m.isError), ...(ids ? { id: m.callId } : {}) } }])
}
return out
}
// ── thinking ──────────────────────────────────────────────────────────────────────────────────
const LEVELS = ["minimal", "low", "medium", "high"] as const
/** The levels a Gemini 3 model takes (OpenCode's table). */
function levelsOf(id: string): readonly string[] {
const s = id.toLowerCase()
if (s.includes("gemma") || s.includes("flash-image")) return ["minimal", "high"]
if (s.includes("pro-image")) return ["high"]
if (s.includes("flash")) return LEVELS
return ["low", "medium", "high"]
}
export function thinkingConfig(id: string, effort: Effort, map?: Partial<Record<Effort, number>>): Record<string, unknown> {
if (map?.[effort]) return { thinkingBudget: map[effort], includeThoughts: true }
const b = bare(id)
if (LEGACY.test(b)) {
// 2.x takes a budget; 2.5 Pro goes to 32k, the rest to 24k.
const max = /gemini-2[.-]5/i.test(b) && /pro/i.test(b) && !/flash/i.test(b) ? 32768 : 24576
return { thinkingBudget: Math.min(DEFAULT_BUDGETS[effort], max), includeThoughts: true }
}
// 3 and later take a level: the one asked for, else the next one up the model has, else its top.
const i = LEVELS.indexOf(effort as (typeof LEVELS)[number])
const want = i < 0 ? LEVELS.length - 1 : i // xhigh and max: the top
const have = levelsOf(b)
const level = LEVELS.slice(want).find((l) => have.includes(l)) ?? have[have.length - 1]
return { thinkingLevel: level, includeThoughts: true }
}
/** Keys sorted, so a call sent again compares equal to itself (as Hermes does). */
const sortedJson = (v: unknown) => JSON.stringify(v, (_, x) => (isObj(x) ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, x[k]])) : x))
const FINISH: Record<string, string> = { STOP: "stop", MAX_TOKENS: "length", SAFETY: "content_filter", RECITATION: "content_filter", BLOCKLIST: "content_filter", PROHIBITED_CONTENT: "content_filter", SPII: "content_filter" }
export class GeminiClient implements Client {
constructor(private m: ResolvedModel) {}
private headers() {
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
}
buildBody(req: ChatRequest): Record<string, unknown> {
const { m } = this
const generationConfig: Record<string, unknown> = {}
if (m.spec.temperature !== undefined) generationConfig.temperature = m.spec.temperature
generationConfig.maxOutputTokens = m.spec.max_output ?? DEFAULT_MAX_OUTPUT
if (m.spec.top_p !== undefined) generationConfig.topP = m.spec.top_p
if (req.effort) generationConfig.thinkingConfig = thinkingConfig(m.id, req.effort, m.spec.effort_map)
const body: Record<string, unknown> = { contents: toGeminiContents(req.messages, m.spec.vision === true, isGemini3(m.id)), generationConfig, ...m.connection.body, ...m.spec.body }
if (req.system) body.systemInstruction = { parts: [{ text: req.system }] }
if (req.tools.length && m.spec.tools !== false) {
const json = acceptsJsonSchema(m.connection.base_url)
body.tools = [{ functionDeclarations: req.tools.map((t) => ({ name: t.name, description: t.description, ...(json ? { parametersJsonSchema: geminiJsonSchema(t.parameters) } : { parameters: geminiParameters(t.parameters) }) })) }]
}
return body
}
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
const body = this.buildBody(req)
yield* retrying(() => this.attempt(req, body))
}
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
const { m } = this
const url = joinUrl(m.connection.base_url, `models/${encodeURIComponent(m.id)}:streamGenerateContent?alt=sse`)
const res = await request(url, { method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) }, m.ref).catch((e) => {
recordFailure(m.ref, body, e)
throw e
})
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
const out = new Parts()
let finish = "stop"
let usage: StreamEvent | undefined
// One slot per call. Gemini 3 ids its calls; 2.5 does not, restarts part numbering in every
// event and may send a call again whole, so a payload repeating (or extending) a slot's arguments
// is that call and anything else is a new one.
const slots = new Map<string, { index: number; part: ToolCallPart }>()
const slotFor = (fc: any, sig: string, at: number, args: string) => {
if (typeof fc.id === "string" && fc.id) return `id:${fc.id}`
const key = `${at}:${fc.name}:${sig}`
const s = slots.get(key)
if (!s || args.startsWith(s.part.args)) return key
for (const [k, o] of slots) if (k.startsWith(`${key}#`) && args.startsWith(o.part.args)) return k
return `${key}#${slots.size}`
}
for await (const { data } of sseJson(record(res.body, m.ref, body))) {
const chunk = data as any
if (chunk?.error) throw new ProviderError(`${m.ref}: ${chunk.error.message ?? JSON.stringify(chunk.error)}`, statusOf(chunk.error) ?? chunk.error.code, undefined, !out.output)
const cand = chunk?.candidates?.[0]
const parts: any[] = cand?.content?.parts ?? []
for (const [at, p] of parts.entries()) {
if (typeof p.text === "string" && p.text) {
const kind = p.thought === true ? "reasoning" : "text"
out.add(kind, p.text)
yield { type: kind, text: p.text }
}
const fc = p.functionCall
if (fc?.name) {
const args = sortedJson(fc.args ?? {})
const sig = typeof p.thoughtSignature === "string" ? p.thoughtSignature : ""
const key = slotFor(fc, sig, at, args)
let s = slots.get(key)
if (!s) {
s = { index: slots.size, part: { type: "tool_call", id: typeof fc.id === "string" && fc.id ? fc.id : `gemini_call_${slots.size}`, name: fc.name, args: "" } }
slots.set(key, s)
out.push(s.part)
}
const prev = s.part.args
s.part.args = args
if (sig) s.part.signature = sig
yield { type: "tool_call_delta", index: s.index, name: fc.name, argsDelta: args.startsWith(prev) ? args.slice(prev.length) : args }
}
}
if (cand?.finishReason) finish = FINISH[String(cand.finishReason).toUpperCase()] ?? "stop"
const u = chunk?.usageMetadata
if (u) usage = { type: "usage", usage: { input: u.promptTokenCount ?? 0, output: (u.candidatesTokenCount ?? 0) + (u.thoughtsTokenCount ?? 0), reasoning: u.thoughtsTokenCount, cached: u.cachedContentTokenCount } }
}
const message = out.message()
if (slots.size && finish !== "length") finish = "tool_calls"
yield usage ?? { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.contents)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
yield { type: "finish", reason: finish, message }
}
async listModels(): Promise<DiscoveredModel[]> {
const res = await request(joinUrl(this.m.connection.base_url, "models?pageSize=200"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
const j = (await res.json()) as any
return (j?.models ?? [])
.filter((x: any) => !x.supportedGenerationMethods || x.supportedGenerationMethods.includes("generateContent"))
.map((x: any) => ({ id: String(x.name ?? "").replace(/^models\//, ""), context: x.inputTokenLimit }))
.filter((x: DiscoveredModel) => x.id)
}
}
+139
View File
@@ -0,0 +1,139 @@
import { readFileSync } from "node:fs"
import { expandHome } from "../config/paths.ts"
import type { Connection } from "../config/schema.ts"
import { ProviderError } from "./types.ts"
import { duration } from "../duration.ts"
const caCache = new Map<string, string>()
/** Bun's fetch `tls` option for a connection, or undefined for the defaults. */
export function tlsFor(c: Pick<Connection, "tls">): { ca?: string; rejectUnauthorized?: boolean } | undefined {
if (!c.tls) return undefined
const out: { ca?: string; rejectUnauthorized?: boolean } = {}
if (c.tls.ca) {
const file = expandHome(c.tls.ca)
let pem = caCache.get(file)
if (!pem) {
try {
pem = readFileSync(file, "utf8")
} catch {
throw new ProviderError(`cannot read tls.ca file ${c.tls.ca}`)
}
caCache.set(file, pem)
}
out.ca = pem
}
if (c.tls.insecure) out.rejectUnauthorized = false
return out
}
const DEFAULT_AUTH: Record<Connection["dialect"], NonNullable<Connection["auth"]>> = {
"openai-chat": "bearer",
responses: "bearer",
anthropic: "x-api-key",
gemini: "x-goog-api-key",
ollama: "bearer",
}
/** Auth plus the connection's and model's own headers. */
export function authHeaders(c: Connection, key: string | undefined, extra: Record<string, string> = {}): Record<string, string> {
const h: Record<string, string> = { "content-type": "application/json", ...extra }
const style = c.auth ?? DEFAULT_AUTH[c.dialect]
if (key && style === "bearer") h.authorization = `Bearer ${key}`
else if (key && style !== "none") h[style] = key
return { ...h, ...c.headers }
}
export function joinUrl(base: string, path: string): string {
return base.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "")
}
/** Turn an HTTP error body into one readable line: provider JSON errors nest the message differently. */
export function describeError(status: number, body: string): string {
let msg = body.trim()
try {
const j = JSON.parse(body) as any
msg = j?.error?.message ?? j?.error ?? j?.message ?? j?.detail ?? msg
if (typeof msg !== "string") msg = JSON.stringify(msg)
} catch {
// not JSON
}
if (msg.length > 600) msg = msg.slice(0, 600) + "…"
return `HTTP ${status}: ${msg || "(empty body)"}`
}
/** At most `max` bytes of a body, as text: an error page can be endless, or a gzip bomb. */
async function cappedText(res: Response, max: number): Promise<string> {
if (!res.body) return ""
const reader = res.body.getReader()
const chunks: Uint8Array[] = []
let size = 0
for (;;) {
const { done, value } = await reader.read()
if (done || !value) break
chunks.push(value)
size += value.length
if (size >= max) {
await reader.cancel().catch(() => {})
break
}
}
return new TextDecoder().decode(Buffer.concat(chunks).subarray(0, max))
}
export async function request(
url: string,
init: RequestInit & { timeoutMs?: number; tls?: { ca?: string; rejectUnauthorized?: boolean } },
what: string,
): Promise<Response> {
// The timeout is for silence, not for length: waiting for the response, then any gap between two
// pieces of the body. A reply may stream for as long as it keeps streaming — with a large
// max_output a model can think for half an hour, and a total limit cut that off mid-reply.
const ms = init.timeoutMs ?? 600_000
const ctl = new AbortController()
let silent = false
let timer = setTimeout(() => ((silent = true), ctl.abort()), ms)
const signal = init.signal ? AbortSignal.any([init.signal, ctl.signal]) : ctl.signal
let res: Response
try {
res = await fetch(url, { ...init, signal })
} catch (e) {
clearTimeout(timer)
const err = e as Error
if (init.signal?.aborted) throw new ProviderError("cancelled")
if (silent || err.name === "TimeoutError") throw new ProviderError(`${what}: timed out — no response in ${duration(ms, true)}`, undefined, undefined, true, true)
const code = (err as { code?: string }).code ?? ""
if (/CERT|SIGNATURE|SELF_SIGNED/.test(code))
throw new ProviderError(`${what}: TLS certificate of ${new URL(url).host} is not trusted (${code}). Install its CA system-wide, or set tls.ca on the connection.`)
throw new ProviderError(`${what}: cannot reach ${new URL(url).host} — ${err.message}`, undefined, undefined, true, true)
}
clearTimeout(timer)
// A redirect asked to be handled by hand is a response, not an error.
if (!res.ok && !(init.redirect === "manual" && res.status >= 300 && res.status < 400)) {
const body = await cappedText(res, 64 * 1024).catch(() => "")
throw new ProviderError(`${what}: ${describeError(res.status, body)}`, res.status, body)
}
if (!res.body) return res
const reader = res.body.getReader()
const body = new ReadableStream<Uint8Array>({
async pull(c) {
timer = setTimeout(() => ((silent = true), ctl.abort()), ms)
try {
const r = await reader.read()
if (r.done) c.close()
else c.enqueue(r.value)
} catch (e) {
if (init.signal?.aborted) throw new ProviderError("cancelled")
if (silent) throw new ProviderError(`${what}: timed out — the server sent nothing for ${duration(ms, true)}`, undefined, undefined, false)
throw e
} finally {
clearTimeout(timer)
}
},
cancel(reason) {
clearTimeout(timer)
return reader.cancel(reason)
},
})
return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers })
}
+70
View File
@@ -0,0 +1,70 @@
import { findRef, modelRefs } from "./refs.ts"
import type { Loaded } from "../config/load.ts"
import { AnthropicClient } from "./anthropic.ts"
import { OpenAIChatClient } from "./openai-chat.ts"
import { ResponsesClient } from "./responses.ts"
import { GeminiClient } from "./gemini.ts"
import { OllamaClient } from "./ollama.ts"
import { ProviderError, type Client, type ResolvedModel } from "./types.ts"
export class ModelError extends Error {}
/** `connection/model`. The model part may itself contain slashes (`openrouter/qwen/qwen3-coder`). */
export function resolveModel(loaded: Loaded, ref: string | undefined): ResolvedModel {
const refs = loaded.refs ?? modelRefs(loaded.connections)
if (!ref) {
// A LLeMbas instance says which model its account starts on; that, before anything else.
const instance = Object.entries(loaded.connections).find(([, c]) => c.webui?.default && c.models[c.webui.default])
if (instance) ref = refs.find((r) => r.connection === instance[0] && r.id === instance[1].webui!.default)?.ref ?? `${instance[0]}/${instance[1].webui!.default}`
}
if (!ref) {
const first = refs[0]
if (!first) throw new ModelError("No model configured. Add a connection to ~/.config/lembas/connections.yaml and set `model:` in config.yaml.")
ref = first.ref
}
// Any form of the name (`deepseek/x`, or the old `example/x`): the model it names, under
// the ref it has now.
const known = findRef(loaded.connections, refs, ref)
if (known) {
const connection = loaded.connections[known.connection]!
if (loaded.broken?.[known.connection]) throw new ModelError(`Connection "${known.connection}" is unusable: ${loaded.broken?.[known.connection]}`)
return { ref: known.ref, connectionName: known.connection, connection, id: known.id, spec: connection.models[known.id] ?? {} }
}
const slash = ref.indexOf("/")
if (slash <= 0) throw new ModelError(`Model "${ref}" must be written connection/model`)
const connectionName = ref.slice(0, slash)
const id = ref.slice(slash + 1)
if (loaded.broken?.[connectionName]) throw new ModelError(`Connection "${connectionName}" is unusable: ${loaded.broken[connectionName]}`)
const connection = loaded.connections[connectionName]
if (!connection) throw new ModelError(`No connection named "${connectionName}" (have: ${Object.keys(loaded.connections).join(", ") || "none"})`)
const spec = connection.models[id]
if (!spec && !connection.discover)
throw new ModelError(`Connection "${connectionName}" has no model "${id}" (have: ${Object.keys(connection.models).join(", ") || "none"}). Add it, or set discover: true.`)
return { ref, connectionName, connection, id, spec: spec ?? {} }
}
export function clientFor(m: ResolvedModel): Client {
switch (m.connection.dialect) {
case "openai-chat":
return new OpenAIChatClient(m)
case "anthropic":
return new AnthropicClient(m)
case "responses":
return new ResponsesClient(m)
case "gemini":
return new GeminiClient(m)
case "ollama":
return new OllamaClient(m)
}
}
/** Which prompt family a model gets when the config does not say: anthropic, gpt, gemini, or
* local — every open-weight or unknown model, which gets Hermes' act-don't-describe guidance. */
export function familyOf(m: ResolvedModel): string {
if (m.spec.family) return m.spec.family
const id = m.id.toLowerCase()
if (id.includes("claude")) return "anthropic"
if (id.includes("gemini")) return "gemini"
if (/\bgpt|^o[134]\b|^o[134]-|codex/.test(id)) return "gpt"
return "local"
}
+69
View File
@@ -0,0 +1,69 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import type { Effort } from "../config/schema.ts"
import { paths } from "../config/paths.ts"
// What endpoints have taught us, kept across runs in ~/.local/state/lembas/learned.json.
// Only ever narrows behaviour; deleting the file is always safe.
interface Learned {
/** `connection/model` → the efforts it actually accepts. */
efforts: Record<string, Effort[]>
/** base URLs that reject `stream_options`. */
noStreamOptions: string[]
/** base URLs that reject `return_progress`. */
noProgress: string[]
/** `connection/model` → context window found by discovery (config always wins). */
contexts: Record<string, number>
}
let cache: Learned | undefined
function file() {
return join(paths.state, "learned.json")
}
export function learned(): Learned {
if (cache) return cache
try {
cache = { efforts: {}, noStreamOptions: [], noProgress: [], contexts: {}, ...JSON.parse(readFileSync(file(), "utf8")) }
} catch {
cache = { efforts: {}, noStreamOptions: [], noProgress: [], contexts: {} }
}
return cache!
}
function save() {
try {
mkdirSync(dirname(file()), { recursive: true })
writeFileSync(file(), JSON.stringify(learned(), null, 2))
} catch {
// bookkeeping must never fail a reply
}
}
export function learnEfforts(ref: string, efforts: Effort[]) {
learned().efforts[ref] = efforts
save()
}
export function learnContext(ref: string, n: number) {
learned().contexts[ref] = n
save()
}
export function learnNoStreamOptions(baseUrl: string) {
const l = learned()
if (!l.noStreamOptions.includes(baseUrl)) l.noStreamOptions.push(baseUrl)
save()
}
export function learnNoProgress(baseUrl: string) {
const l = learned()
if (!l.noProgress.includes(baseUrl)) l.noProgress.push(baseUrl)
save()
}
/** Tests only. */
export function resetLearned() {
cache = undefined
}
+178
View File
@@ -0,0 +1,178 @@
// Ollama, native (`POST {base}/api/chat`, NDJSON), base e.g. http://localhost:11434. Its
// OpenAI-compatible /v1 works through openai-chat too; the native API adds num_ctx (without it
// Ollama silently cuts every conversation to its small default window) and `think` levels.
// UNTESTED: no Ollama server has been available to try it against (see the wiki). Checked against
// Hermes Agent's handling (agent/model_metadata.py, agent/reasoning_params.py): /api/show decides
// the window to ask for and whether `think` may be sent at all.
import { resolveKey } from "../config/load.ts"
import { Parts, record, recordFailure, retrying, statusOf } from "./common.ts"
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
import { estimateTokens } from "./tokens.ts"
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent } from "./types.ts"
export function toOllamaMessages(system: string, messages: Message[], vision: boolean): Record<string, unknown>[] {
const out: Record<string, unknown>[] = []
if (system) out.push({ role: "system", content: system })
for (const m of messages) {
if (m.role === "user") {
const images = vision ? m.parts.filter((p) => p.type === "image").map((p) => (p as { data: string }).data) : []
const text = m.parts.map((p) => (p.type === "text" ? p.text : vision ? "" : "[image omitted: this model has no vision]")).filter(Boolean).join("\n")
out.push({ role: "user", content: text, ...(images.length ? { images } : {}) })
} else if (m.role === "assistant") {
const text = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("")
const thinking = m.parts.map((p) => (p.type === "reasoning" ? p.text : "")).join("")
const calls = m.parts.flatMap((p) => {
if (p.type !== "tool_call") return []
let args: unknown = {}
try {
args = p.args.trim() ? JSON.parse(p.args) : {}
} catch {}
return [{ function: { name: p.name, arguments: args } }]
})
out.push({ role: "assistant", content: text, ...(thinking ? { thinking } : {}), ...(calls.length ? { tool_calls: calls } : {}) })
} else out.push({ role: "tool", content: m.content, tool_name: m.name })
}
return out
}
/** NDJSON: one JSON object per line. */
async function* ndjson(body: ReadableStream<Uint8Array>): AsyncGenerator<unknown> {
const dec = new TextDecoder()
let buf = ""
for await (const chunk of body) {
buf += dec.decode(chunk, { stream: true })
let nl: number
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim()
buf = buf.slice(nl + 1)
if (line)
try {
yield JSON.parse(line)
} catch {}
}
}
const last = (buf + dec.decode()).trim()
if (last)
try {
yield JSON.parse(last)
} catch {}
}
/** What /api/show says about a model. */
export interface OllamaShow {
/** "completion", "tools", "thinking", "vision"…; absent before Ollama 0.6. */
capabilities?: string[]
/** The runtime window the Modelfile sets (`num_ctx`), which the owner of the server chose. */
numCtx?: number
/** The trained maximum (`model_info["<arch>.context_length"]`). */
trained?: number
}
export function parseShow(j: any): OllamaShow {
const out: OllamaShow = {}
if (Array.isArray(j?.capabilities)) out.capabilities = j.capabilities.map(String)
for (const line of String(j?.parameters ?? "").split("\n")) {
const f = line.trim().split(/\s+/)
if (f[0] === "num_ctx" && Number(f[1]) > 0) out.numCtx = Number(f[1])
}
const info = j?.model_info ?? {}
const key = Object.keys(info).find((k) => k.endsWith(".context_length"))
if (key && Number(info[key]) > 0) out.trained = Number(info[key])
return out
}
export class OllamaClient implements Client {
private shown?: Promise<OllamaShow | undefined>
constructor(private m: ResolvedModel) {}
/** /api/show for this model, asked once; undefined when the server does not answer it. */
show(id = this.m.id): Promise<OllamaShow | undefined> {
const ask = async () => {
const res = await request(joinUrl(this.m.connection.base_url, "api/show"), { method: "POST", headers: this.headers(), body: JSON.stringify({ model: id, name: id }), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
return parseShow(await res.json())
}
if (id !== this.m.id) return ask().catch(() => undefined)
return (this.shown ??= ask().catch(() => undefined))
}
private headers() {
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
}
buildBody(req: ChatRequest, show?: OllamaShow): Record<string, unknown> {
const { m } = this
const options: Record<string, unknown> = {}
// Without num_ctx Ollama cuts every conversation to its small default window. Configured wins;
// else the Modelfile's, else the trained maximum (set `context` to cap it to what memory holds).
const ctx = m.spec.context ?? show?.numCtx ?? show?.trained
if (ctx) options.num_ctx = ctx
if (m.spec.max_output) options.num_predict = m.spec.max_output
if (m.spec.temperature !== undefined) options.temperature = m.spec.temperature
if (m.spec.top_p !== undefined) options.top_p = m.spec.top_p
const body: Record<string, unknown> = { model: m.id, messages: toOllamaMessages(req.system, req.messages, m.spec.vision === true), stream: true, options, ...m.connection.body, ...m.spec.body }
if (req.tools.length && m.spec.tools !== false)
body.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }))
// gpt-oss takes a level; other thinking models take a switch. A model /api/show says cannot
// think gets no `think` at all: Ollama refuses the request.
const thinks = !show?.capabilities || show.capabilities.includes("thinking")
if (thinks && req.effort) body.think = /gpt-oss/.test(m.id) ? (req.effort === "minimal" ? "low" : req.effort === "xhigh" || req.effort === "max" ? "high" : req.effort) : true
else if (thinks && m.spec.efforts?.length) body.think = false
return body
}
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
const body = this.buildBody(req, await this.show())
yield* retrying(() => this.attempt(req, body))
}
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
const { m } = this
const res = await request(joinUrl(m.connection.base_url, "api/chat"), { method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) }, m.ref).catch((e) => {
recordFailure(m.ref, body, e)
throw e
})
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
const out = new Parts()
let finish = "stop"
let usage: StreamEvent | undefined
let calls = 0
for await (const chunk of ndjson(record(res.body, m.ref, body)) as AsyncGenerator<any>) {
if (chunk?.error) throw new ProviderError(`${m.ref}: ${chunk.error}`, statusOf(chunk) ?? 500, undefined, !out.output)
const msg = chunk?.message ?? {}
if (msg.thinking) {
out.add("reasoning", msg.thinking)
yield { type: "reasoning", text: msg.thinking }
}
if (msg.content) {
out.add("text", msg.content)
yield { type: "text", text: msg.content }
}
for (const tc of msg.tool_calls ?? []) {
const args = JSON.stringify(tc.function?.arguments ?? {})
yield { type: "tool_call_delta", index: calls, name: tc.function?.name, argsDelta: args }
out.push({ type: "tool_call", id: tc.id ?? `ollama_call_${calls}`, name: tc.function?.name ?? "", args })
calls++
}
if (chunk?.done) {
finish = chunk.done_reason === "length" ? "length" : "stop"
if (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined) usage = { type: "usage", usage: { input: chunk.prompt_eval_count ?? 0, output: chunk.eval_count ?? 0 } }
}
}
const message = out.message()
if (calls && finish !== "length") finish = "tool_calls"
yield usage ?? { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true } }
yield { type: "finish", reason: finish, message }
}
async listModels(): Promise<DiscoveredModel[]> {
const res = await request(joinUrl(this.m.connection.base_url, "api/tags"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
const j = (await res.json()) as any
return (j?.models ?? []).map((x: any) => ({ id: String(x.name ?? x.model ?? "") })).filter((x: DiscoveredModel) => x.id)
}
/** The model's window: the Modelfile's num_ctx, else its trained maximum. */
async contextOf(id: string): Promise<number | undefined> {
const s = await this.show(id)
return s?.numCtx ?? s?.trained
}
}
+296
View File
@@ -0,0 +1,296 @@
// OpenAI chat completions — and every server that imitates it: llama.cpp / llama-swap, vLLM,
// LM Studio, Ollama's /v1, OpenRouter, DeepSeek. The quirks handled here are the ones LLeMbas
// catalogued in production (see its Working-notes): usage that is never sent, `stream_options`
// that is a 400, reasoning in `reasoning_content` or `reasoning` or inline <think> tags, tool-call
// fragments without an index, arguments sent as an object, and chat templates that refuse efforts.
import type { Effort } from "../config/schema.ts"
import { resolveKey } from "../config/load.ts"
import { advertisedEfforts, applyEffort, effortRefused, effortsFor, stripEffort } from "./effort.ts"
import { authHeaders, request, joinUrl, tlsFor } from "./http.ts"
import { learned, learnEfforts, learnNoProgress, learnNoStreamOptions } from "./learned.ts"
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
import { sseJson } from "./sse.ts"
import { ThinkSplitter } from "./think.ts"
import { estimateTokens } from "./tokens.ts"
import {
ProviderError,
type ChatRequest,
type Client,
type DiscoveredModel,
type Message,
type ResolvedModel,
type StreamEvent,
type ToolCallPart,
type ReasoningPart,
type TextPart,
} from "./types.ts"
type Assistant = Extract<Message, { role: "assistant" }>
export function toOpenAIMessages(system: string, messages: Message[], vision: boolean): unknown[] {
const out: unknown[] = []
if (system) out.push({ role: "system", content: system })
for (const m of messages) {
if (m.role === "user") {
const images = m.parts.filter((p) => p.type === "image")
if (images.length === 0 || !vision) {
const text = m.parts.map((p) => (p.type === "text" ? p.text : "[image omitted: model has no vision]")).join("\n")
out.push({ role: "user", content: text })
} else {
out.push({
role: "user",
content: m.parts.map((p) =>
p.type === "text"
? { type: "text", text: p.text }
: { type: "image_url", image_url: { url: `data:${p.mime};base64,${p.data}` } },
),
})
}
} else if (m.role === "assistant") {
const text = m.parts.filter((p): p is TextPart => p.type === "text").map((p) => p.text).join("")
const calls = m.parts.filter((p): p is ToolCallPart => p.type === "tool_call")
const msg: Record<string, unknown> = { role: "assistant", content: text || null }
if (calls.length)
msg.tool_calls = calls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: historyArgs(c.args) } }))
out.push(msg)
} else {
out.push({ role: "tool", tool_call_id: m.callId, content: m.content })
}
}
return out
}
/** Reassembles streamed tool-call fragments, keyed by index (the only field on every fragment). */
export class ToolCallAccumulator {
private calls = new Map<number, { id: string; name: string; args: string }>()
feed(fragments: unknown): { index: number; name?: string; argsDelta: string }[] {
const deltas: { index: number; name?: string; argsDelta: string }[] = []
if (!Array.isArray(fragments)) return deltas
for (const f of fragments as any[]) {
if (!f || typeof f !== "object") continue
// Some servers omit index when there is only one call.
const index = Number.isInteger(f.index) ? (f.index as number) : 0
let call = this.calls.get(index)
if (!call) this.calls.set(index, (call = { id: "", name: "", args: "" }))
if (f.id) call.id = String(f.id)
const fn = f.function ?? {}
let name: string | undefined
if (fn.name) name = call.name = String(fn.name)
let delta = ""
if (typeof fn.arguments === "string") delta = fn.arguments
else if (fn.arguments && typeof fn.arguments === "object") delta = JSON.stringify(fn.arguments)
call.args += delta
deltas.push({ index, name, argsDelta: delta })
}
return deltas
}
result(): ToolCallPart[] {
return [...this.calls.entries()]
.sort(([a], [b]) => a - b)
.filter(([, c]) => c.name)
.map(([i, c]) => ({ type: "tool_call", id: c.id || `call_${i}_${Date.now().toString(36)}`, name: c.name, args: c.args }))
}
}
function textOf(content: unknown): string {
if (typeof content === "string") return content
if (Array.isArray(content))
return content.map((p: any) => (typeof p === "string" ? p : typeof p?.text === "string" ? p.text : "")).join("")
return ""
}
export class OpenAIChatClient implements Client {
constructor(private m: ResolvedModel) {}
private headers(): Record<string, string> {
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
}
buildBody(req: ChatRequest): Record<string, unknown> {
const { m } = this
const body: Record<string, unknown> = {
model: m.id,
messages: toOpenAIMessages(req.system, req.messages, m.spec.vision === true),
stream: true,
...m.connection.body,
...m.spec.body,
}
if (req.tools.length && m.spec.tools !== false)
body.tools = req.tools.map((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }))
if (m.spec.max_output) body[m.connection.quirks?.max_tokens_field ?? "max_tokens"] = m.spec.max_output
if (m.spec.temperature !== undefined) body.temperature = m.spec.temperature
if (m.spec.top_p !== undefined) body.top_p = m.spec.top_p
applyEffort(body, m, req.effort)
return body
}
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
const { m } = this
const base = m.connection.base_url
let body = this.buildBody(req)
const usageQuirk = m.connection.quirks?.stream_usage ?? "auto"
let wantUsage = usageQuirk === "on" || (usageQuirk === "auto" && !learned().noStreamOptions.includes(base))
// llama.cpp says how far it is through reading the prompt — minutes, for a long conversation
// on a local model. Asked for everywhere; a server that ignores it sends nothing more, and one
// that refuses it is retried without and remembered.
const progressQuirk = m.connection.quirks?.prompt_progress ?? "auto"
let wantProgress = progressQuirk === "on" || (progressQuirk === "auto" && !learned().noProgress.includes(base))
let droppedProgress = false
// Beyond the shared rule, three quirks of OpenAI-compatible servers are retried: a chat template
// refusing the effort (llama.cpp — learned, then dropped), and a 400 for return_progress, then
// for stream_options.
yield* retrying(
() => this.attempt(req, body, wantUsage, wantProgress),
(e) => {
const refused = (body.reasoning_effort ?? (body.chat_template_kwargs as any)?.reasoning_effort) as Effort | undefined
if (refused && effortRefused(e.message)) {
const advertised = advertisedEfforts(e.message).filter((x) => x !== refused)
const narrowed = advertised.length ? advertised : effortsFor(m).filter((x) => x !== refused)
if (narrowed.length) learnEfforts(m.ref, narrowed)
body = stripEffort(body)
return { retry: true, notice: `${m.ref} refused effort "${refused}"; retried without it (now offering ${narrowed.join(", ") || "none"})` }
}
// A 400 that names stream_options is about that; one that names neither field drops
// progress first (the newer and rarer of the two).
if (wantProgress && progressQuirk === "auto" && (e.status === 400 || e.status === 422) && !/stream_options/i.test(e.message)) {
wantProgress = false
droppedProgress = true
// Remembered at once when the server names it; otherwise only if the retry then works,
// so a 400 for something else (a prompt too long) does not switch progress off for good.
if (/return_progress/i.test(e.message)) learnNoProgress(base)
return { retry: true }
}
if (wantUsage && usageQuirk === "auto" && (e.status === 400 || e.status === 422)) {
wantUsage = false
learnNoStreamOptions(base)
return { retry: true }
}
return { retry: false }
},
)
if (droppedProgress) learnNoProgress(base)
}
private async *attempt(req: ChatRequest, body: Record<string, unknown>, wantUsage: boolean, wantProgress = false): AsyncGenerator<StreamEvent> {
const { m } = this
const sent = { ...body, ...(wantUsage ? { stream_options: { include_usage: true } } : {}), ...(wantProgress ? { return_progress: true } : {}) }
const res = await request(
joinUrl(m.connection.base_url, "chat/completions"),
{
method: "POST",
headers: this.headers(),
body: JSON.stringify(sent),
signal: req.signal,
timeoutMs: (m.connection.timeout ?? 600) * 1000,
tls: tlsFor(m.connection),
},
m.ref,
).catch((e) => {
recordFailure(m.ref, sent, e)
throw e
})
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
const stream = record(res.body, m.ref, sent)
const splitter = m.connection.quirks?.think_tags === "off" ? undefined : new ThinkSplitter()
const acc = new ToolCallAccumulator()
const banner = new SwapBanner()
const out = new Parts()
const addText = (kind: "text" | "reasoning", text: string) => out.add(kind, text)
let finish = "stop"
let usage: StreamEvent | undefined
for await (const { data } of sseJson(stream)) {
const chunk = data as any
if (chunk?.error) {
const err = chunk.error
const msg = typeof err === "string" ? err : (err.message ?? JSON.stringify(err))
// llama-swap's "group: model unloaded" (another request swapped the model out) carries a
// string code; statusOf() still reads it as the server error it is.
throw new ProviderError(`${m.ref}: ${String(msg).trim()}`, statusOf(err), undefined, !out.output)
}
if (chunk?.usage && typeof chunk.usage === "object") {
const u = chunk.usage
usage = {
type: "usage",
usage: {
input: u.prompt_tokens ?? 0,
output: u.completion_tokens ?? 0,
reasoning: u.completion_tokens_details?.reasoning_tokens,
cached: u.prompt_tokens_details?.cached_tokens ?? u.cache_read_input_tokens,
},
}
}
// llama.cpp, while it reads the prompt: { total, cache, processed, time_ms }. Anything not
// shaped like that is ignored, never an error.
const pp = chunk?.prompt_progress
if (pp && typeof pp === "object" && Number.isFinite(pp.total) && pp.total > 0 && !out.output)
yield { type: "progress", total: Number(pp.total), cache: Number(pp.cache) || 0, processed: Number(pp.processed) || 0, ms: Number(pp.time_ms) || 0 }
const choice = chunk?.choices?.[0]
if (!choice) continue
const delta = choice.delta ?? choice.message ?? {}
let reasoning = delta.reasoning_content ?? delta.reasoning
if (typeof reasoning === "string" && reasoning) {
const b = banner.feed(reasoning)
if (b.notice) yield { type: "notice", message: b.notice }
reasoning = b.text
}
if (typeof reasoning === "string" && reasoning) {
addText("reasoning", reasoning)
yield { type: "reasoning", text: reasoning }
}
const content = textOf(delta.content)
if (content) {
for (const piece of splitter ? splitter.feed(content) : [{ kind: "text" as const, text: content }]) {
addText(piece.kind, piece.text)
yield { type: piece.kind, text: piece.text }
}
}
for (const d of acc.feed(delta.tool_calls)) {
out.output = true
yield { type: "tool_call_delta", ...d }
}
if (choice.finish_reason) finish = String(choice.finish_reason)
}
for (const piece of splitter?.flush() ?? []) {
addText(piece.kind, piece.text)
yield { type: piece.kind, text: piece.text }
}
const calls = acc.result()
for (const c of calls) out.push(c)
// finish_reason is a hint only: some servers say "stop" after tool calls.
// …except "length": a call cut off at the output limit has broken arguments, and the engine says so.
if (calls.length && finish !== "length") finish = "tool_calls"
const message = out.message()
const parts = message.parts
if (!usage) {
const out = parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.name + p.args : p.text), 0)
usage = { type: "usage", usage: { input: estimateTokens(JSON.stringify(body.messages)), output: out, estimated: true } }
}
yield usage
yield { type: "finish", reason: finish, message }
}
async listModels(): Promise<DiscoveredModel[]> {
const res = await request(joinUrl(this.m.connection.base_url, "models"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
const j = (await res.json()) as any
const list: any[] = Array.isArray(j) ? j : Array.isArray(j?.data) ? j.data : Array.isArray(j?.models) ? j.models : []
return list
.map((x) => ({ id: String(x?.id ?? x?.name ?? ""), context: contextFrom(x) }))
.filter((x) => x.id)
}
}
/** Context window from a /models entry — every server names it differently. */
export function contextFrom(x: any): number | undefined {
for (const v of [x?.context_length, x?.max_model_len, x?.context_window, x?.max_context_length, x?.meta?.n_ctx, x?.meta?.n_ctx_train]) {
const n = Number(v)
if (Number.isFinite(n) && n > 0) return n
}
return undefined
}
+84
View File
@@ -0,0 +1,84 @@
// How a model is named: by its provider, not by the login it came through. A LLeMbas
// instance that speaks protocol 2 serves every model as `<provider>/<model>` — `deepseek/deepseek-flash`,
// `llama/bonsai` — and that id is the model's ref here too: each provider is a connection of its
// own as far as the person can see, backed by the instance (requests still go to its /v1, with the
// served id). The older form of the ref was `<login connection>/<id>` (`example/deepseek-flash`).
//
// - An instance that does not speak protocol 2 serves bare ids: those stay `<login>/<id>`.
// - A connection of the user's own with the provider's name (`deepseek:` in connections.yaml)
// keeps `deepseek/x`; the instance's is then `<login>/deepseek/x`.
// - Two instances serving the same provider: the one logged in to first keeps the short form, the
// other's are `<login>/<provider>/<model>`.
// - The old forms still resolve — `example/deepseek-flash` and `example/deepseek/deepseek-flash`
// are `deepseek/deepseek-flash` — and a config.yaml that names one is rewritten to the new form.
import type { Connection } from "../config/schema.ts"
export interface ModelRef {
/** What the person sees and writes. */
ref: string
/** The connection it is spoken to through, and the id sent there. */
connection: string
id: string
/** An instance's model: its provider, when known — what the old `<login>/<model>` is matched by. */
provider?: string
}
type Conns = Record<string, Connection & { webui?: { providers?: Record<string, string>; v2?: boolean } }>
/** The provider of a served id: as the instance said it (`provider`), else — from an
* instance that speaks protocol 2, whose ids are always `<provider>/<model>` — the part before the
* first `/`. An id with a `/` from an older instance is just an id. */
export function providerOf(c: Conns[string], id: string): string | undefined {
if (!c.webui) return undefined
const said = c.webui.providers?.[id]
if (said) return said
return c.webui.v2 && id.indexOf("/") > 0 ? id.slice(0, id.indexOf("/")) : undefined
}
/** Every model, in config order, with its ref. `logins`: the webui connections in the order they
* were logged in to (first keeps a shared provider's short form). */
export function modelRefs(connections: Conns, logins: string[] = []): ModelRef[] {
const names = new Set(Object.keys(connections))
const webui = Object.keys(connections).filter((n) => connections[n]!.webui)
const order = [...logins.filter((n) => webui.includes(n)), ...webui.filter((n) => !logins.includes(n))]
// Which webui connection a provider's short form belongs to.
const owner = new Map<string, string>()
for (const w of order)
for (const id of Object.keys(connections[w]!.models)) {
const provider = providerOf(connections[w]!, id)
if (!provider) continue
// A connection of that name wins it — unless it is this login itself (`llama/bonsai` on a
// login named llama is the same string either way).
if ((names.has(provider) && provider !== w) || owner.has(provider)) continue
owner.set(provider, w)
}
const out: ModelRef[] = []
for (const [name, c] of Object.entries(connections))
for (const id of Object.keys(c.models)) {
const provider = providerOf(c, id)
const short = provider && owner.get(provider) === name
out.push({ ref: short ? id : `${name}/${id}`, connection: name, id, ...(provider ? { provider } : {}) })
}
return out
}
/** A ref as written — any of the forms above — as the model it names, or undefined. `old`: it was
* the form from before providers (`<login>/<model>` for a served `<provider>/<model>`). */
export function findRef(connections: Conns, refs: ModelRef[], ref: string): (ModelRef & { old?: boolean }) | undefined {
const exact = refs.find((r) => r.ref === ref)
if (exact) return exact
const slash = ref.indexOf("/")
if (slash <= 0) return undefined
const conn = ref.slice(0, slash)
const id = ref.slice(slash + 1)
const c = connections[conn]
if (!c) return undefined
const direct = refs.find((r) => r.connection === conn && r.id === id)
if (direct) return direct
// `<login>/<model>` from before providers: the one served model of that name, whatever its provider.
if (c.webui && !id.includes("/")) {
const by = refs.filter((r) => r.connection === conn && r.provider && r.id === `${r.provider}/${id}`)
if (by.length === 1) return { ...by[0]!, old: true }
}
return undefined
}
+174
View File
@@ -0,0 +1,174 @@
// OpenAI Responses (`POST {base}/responses`) — api.openai.com, and vLLM. Stateless (store: false):
// the whole history is sent as input items, and reasoning items come back opaque (with their
// encrypted content when the server provides it) so a reasoning model keeps its thread.
import { resolveKey } from "../config/load.ts"
import { historyArgs, Parts, record, recordFailure, retrying, statusOf, SwapBanner } from "./common.ts"
import { authHeaders, joinUrl, request, tlsFor } from "./http.ts"
import { contextFrom } from "./openai-chat.ts"
import { sseJson } from "./sse.ts"
import { estimateTokens } from "./tokens.ts"
import { ProviderError, type ChatRequest, type Client, type DiscoveredModel, type Message, type ResolvedModel, type StreamEvent } from "./types.ts"
type Item = Record<string, unknown>
export function toResponsesInput(messages: Message[], vision: boolean): Item[] {
const out: Item[] = []
for (const m of messages) {
if (m.role === "user") {
out.push({
role: "user",
content: m.parts.map((p) =>
p.type === "text"
? { type: "input_text", text: p.text }
: vision
? { type: "input_image", image_url: `data:${p.mime};base64,${p.data}` }
: { type: "input_text", text: "[image omitted: this model has no vision]" },
),
})
} else if (m.role === "assistant") {
let text: string[] = []
const flush = () => {
if (text.join("").trim()) out.push({ role: "assistant", content: [{ type: "output_text", text: text.join("") }] })
text = []
}
for (const p of m.parts) {
if (p.type === "text") text.push(p.text)
else if (p.type === "reasoning") {
// Only a reasoning item this dialect produced can go back; text from elsewhere cannot.
const item = (p.opaque as { responses?: Item } | undefined)?.responses
if (item) {
flush()
out.push(item)
}
} else {
flush()
out.push({ type: "function_call", call_id: p.id, name: p.name, arguments: historyArgs(p.args) })
}
}
flush()
} else out.push({ type: "function_call_output", call_id: m.callId, output: m.content })
}
return out
}
export class ResponsesClient implements Client {
constructor(private m: ResolvedModel) {}
private headers() {
return authHeaders(this.m.connection, resolveKey(this.m.connectionName, this.m.connection), this.m.spec.headers)
}
buildBody(req: ChatRequest): Record<string, unknown> {
const { m } = this
const body: Record<string, unknown> = {
model: m.id,
input: toResponsesInput(req.messages, m.spec.vision === true),
stream: true,
store: false,
...m.connection.body,
...m.spec.body,
}
if (req.system) body.instructions = req.system
if (req.tools.length && m.spec.tools !== false)
body.tools = req.tools.map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false }))
if (m.spec.max_output) body.max_output_tokens = m.spec.max_output
if (req.effort) {
body.reasoning = { effort: req.effort, summary: "auto" }
body.include = ["reasoning.encrypted_content"]
} else {
if (m.spec.temperature !== undefined) body.temperature = m.spec.temperature
if (m.spec.top_p !== undefined) body.top_p = m.spec.top_p
}
return body
}
async *stream(req: ChatRequest): AsyncGenerator<StreamEvent> {
let body = this.buildBody(req)
yield* retrying(
() => this.attempt(req, body),
(e) => {
// Some servers do not know `include`; drop it once rather than fail.
if (body.include && (e.status === 400 || e.status === 422) && /include/i.test(e.message)) {
const { include: _, ...rest } = body
body = rest
return { retry: true }
}
return { retry: false }
},
)
}
private async *attempt(req: ChatRequest, body: Record<string, unknown>): AsyncGenerator<StreamEvent> {
const { m } = this
const res = await request(
joinUrl(m.connection.base_url, "responses"),
{ method: "POST", headers: this.headers(), body: JSON.stringify(body), signal: req.signal, timeoutMs: (m.connection.timeout ?? 600) * 1000, tls: tlsFor(m.connection) },
m.ref,
).catch((e) => {
recordFailure(m.ref, body, e)
throw e
})
if (!res.body) throw new ProviderError(`${m.ref}: empty response`)
const out = new Parts()
const banner = new SwapBanner()
const reasoning = new Map<string, string>()
const callIndex = new Map<string, number>()
let finish = "stop"
let usage: StreamEvent | undefined
for await (const { data } of sseJson(record(res.body, m.ref, body))) {
const ev = data as any
const t: string = ev?.type ?? ""
if (t === "response.output_text.delta") {
out.add("text", ev.delta ?? "")
yield { type: "text", text: ev.delta ?? "" }
} else if (t === "response.reasoning_summary_text.delta" || t === "response.reasoning_text.delta") {
const b = banner.feed(ev.delta ?? "")
if (b.notice) yield { type: "notice", message: b.notice }
if (!b.text) continue
reasoning.set(ev.item_id ?? "", (reasoning.get(ev.item_id ?? "") ?? "") + b.text)
if (b.text.trim()) out.output = true
yield { type: "reasoning", text: b.text }
} else if (t === "response.output_item.added" && ev.item?.type === "function_call") {
const index = callIndex.size
callIndex.set(ev.item.id ?? ev.item.call_id, index)
yield { type: "tool_call_delta", index, name: ev.item.name, argsDelta: "" }
} else if (t === "response.function_call_arguments.delta") {
out.output = true
yield { type: "tool_call_delta", index: callIndex.get(ev.item_id) ?? 0, argsDelta: ev.delta ?? "" }
} else if (t === "response.output_item.done") {
const item = ev.item ?? {}
if (item.type === "function_call") out.push({ type: "tool_call", id: item.call_id ?? item.id, name: item.name, args: item.arguments || "{}" })
else if (item.type === "reasoning") {
const summary = Array.isArray(item.summary) ? item.summary.map((s: any) => s.text ?? "").join("\n") : ""
const text = reasoning.get(item.id ?? "") || summary
// vLLM puts the raw reasoning in `content`, which it will not accept back; keep what can return.
const { content: _c, ...back } = item
if (text || item.encrypted_content) out.push({ type: "reasoning", text, opaque: item.encrypted_content ? { responses: back } : undefined })
}
} else if (t === "response.completed" || t === "response.incomplete") {
const r = ev.response ?? {}
if (t === "response.incomplete" || r.status === "incomplete") finish = "length"
const u = r.usage
if (u) usage = { type: "usage", usage: { input: u.input_tokens ?? 0, output: u.output_tokens ?? 0, reasoning: u.output_tokens_details?.reasoning_tokens, cached: u.input_tokens_details?.cached_tokens || undefined } }
} else if (t === "response.failed" || t === "error" || ev?.error) {
const err = ev.response?.error ?? ev.error ?? ev
throw new ProviderError(`${m.ref}: ${err.message ?? JSON.stringify(err)}`, statusOf(err), undefined, !out.output)
}
}
const message = out.message()
if (finish !== "length" && message.parts.some((p) => p.type === "tool_call")) finish = "tool_calls"
yield usage ?? {
type: "usage",
usage: { input: estimateTokens(JSON.stringify(body.input)), output: message.parts.reduce((n, p) => n + estimateTokens(p.type === "tool_call" ? p.args : p.text), 0), estimated: true },
}
yield { type: "finish", reason: finish, message }
}
async listModels(): Promise<DiscoveredModel[]> {
const res = await request(joinUrl(this.m.connection.base_url, "models"), { headers: this.headers(), timeoutMs: 15_000, tls: tlsFor(this.m.connection) }, this.m.connectionName)
const j = (await res.json()) as any
return (Array.isArray(j?.data) ? j.data : []).map((x: any) => ({ id: String(x.id), context: contextFrom(x) })).filter((x: DiscoveredModel) => x.id)
}
}
+87
View File
@@ -0,0 +1,87 @@
// Server-sent events → parsed JSON objects. Keep-alive comments and non-data lines are skipped;
// a malformed frame is skipped rather than killing the reply (LLeMbas openai_client._stream_once).
// Errors are surfaced in every shape seen in the wild — see the comments in `line()`.
export async function* sseJson(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: unknown }> {
const decoder = new TextDecoder()
let buf = ""
let event: string | undefined
let data: string[] = []
const out: { event?: string; data: unknown }[] = []
const dispatch = () => {
if (data.length === 0) return
const raw = data.join("\n")
data = []
const ev = event
event = undefined
if (raw === "[DONE]") return
try {
out.push({ event: ev, data: JSON.parse(raw) as unknown })
} catch {
// unparseable frame: skip
}
}
const line = (l: string) => {
if (l.endsWith("\r")) l = l.slice(0, -1)
if (l === "") return dispatch()
if (l.startsWith(":")) return
// llama.cpp (seen behind llama-swap) writes a mid-stream failure as a bare JSON line with no
// field name: `{"error":{"code":500,"message":"…"}}` — and as the stream's LAST line, with no
// newline after it. By the SSE rules it is a field called `{"error"` and would be ignored,
// silently ending the reply.
if (l.startsWith("{")) {
try {
const bare = JSON.parse(l) as unknown
if (bare && typeof bare === "object" && "error" in bare) {
dispatch()
out.push({ event: "error", data: bare })
return
}
} catch {}
}
const colon = l.indexOf(":")
const field = colon === -1 ? l : l.slice(0, colon)
let value = colon === -1 ? "" : l.slice(colon + 1)
if (value.startsWith(" ")) value = value.slice(1)
if (field === "data") {
// Some servers omit the blank line between events; a complete JSON frame on its own
// `data:` line is dispatched before the next one starts.
if (data.length > 0 && isJson(data.join("\n"))) dispatch()
data.push(value)
} else if (field === "event") event = value
else if (field === "error") {
// Some servers name the field `error:`. Same treatment as the bare JSON line.
let parsed: unknown = value
try {
parsed = JSON.parse(value)
} catch {}
dispatch()
out.push({ event: "error", data: parsed && typeof parsed === "object" && "error" in parsed ? parsed : { error: parsed } })
}
}
for await (const chunk of body) {
buf += decoder.decode(chunk, { stream: true })
let nl: number
while ((nl = buf.indexOf("\n")) !== -1) {
line(buf.slice(0, nl))
buf = buf.slice(nl + 1)
}
yield* out.splice(0)
}
buf += decoder.decode()
// The last line may have no newline after it — and that is exactly where an error lands.
if (buf) line(buf)
dispatch()
yield* out.splice(0)
}
function isJson(s: string): boolean {
try {
JSON.parse(s)
return true
} catch {
return false
}
}
+85
View File
@@ -0,0 +1,85 @@
// Ported from LLeMbas services/reasoning.py (© Jaroslav Beneš, MIT).
// Splits inline <think>…</think> (and <thinking>, <reasoning>) out of streamed content.
// Tags arrive split across chunks, so this scans as a stream and holds back only
// as much as a partial tag could occupy.
const TAGS: [string, string][] = [
["<thinking>", "</thinking>"],
["<think>", "</think>"],
["<reasoning>", "</reasoning>"],
]
export type Piece = { kind: "reasoning" | "text"; text: string }
export class ThinkSplitter {
private buf = ""
private closing = ""
feed(chunk: string): Piece[] {
this.buf += chunk
return this.drain(false)
}
flush(): Piece[] {
return this.drain(true)
}
private drain(final: boolean): Piece[] {
const out: Piece[] = []
while (this.buf) {
if (this.closing) {
const at = this.buf.indexOf(this.closing)
if (at === -1) {
const keep = final ? 0 : partialSuffix(this.buf, [this.closing])
push(out, "reasoning", this.buf.slice(0, this.buf.length - keep))
this.buf = this.buf.slice(this.buf.length - keep)
return out
}
push(out, "reasoning", this.buf.slice(0, at))
this.buf = this.buf.slice(at + this.closing.length)
this.closing = ""
continue
}
let best = -1
let open = ""
let close = ""
for (const [o, c] of TAGS) {
const i = this.buf.indexOf(o)
if (i !== -1 && (best === -1 || i < best)) [best, open, close] = [i, o, c]
}
if (best === -1) {
// Unlike the original, hold back only a tail that could really start a tag,
// so ordinary text is never delayed.
const keep = final ? 0 : partialSuffix(this.buf, TAGS.map((t) => t[0]))
push(out, "text", this.buf.slice(0, this.buf.length - keep))
this.buf = this.buf.slice(this.buf.length - keep)
return out
}
push(out, "text", this.buf.slice(0, best))
this.buf = this.buf.slice(best + open.length)
this.closing = close
}
return out
}
}
function push(out: Piece[], kind: Piece["kind"], text: string) {
if (!text) return
const last = out[out.length - 1]
if (last && last.kind === kind) last.text += text
else out.push({ kind, text })
}
/** Length of the longest suffix of `s` that is a proper prefix of one of `tags`. */
function partialSuffix(s: string, tags: string[]): number {
let best = 0
for (const tag of tags) {
for (let n = Math.min(tag.length - 1, s.length); n > best; n--) {
if (s.endsWith(tag.slice(0, n))) {
best = n
break
}
}
}
return best
}
+6
View File
@@ -0,0 +1,6 @@
// Rough token estimate for endpoints that report no usage (LLeMbas services/tokens.py).
export const CHARS_PER_TOKEN = 4
export function estimateTokens(text: string): number {
return Math.ceil(text.length / CHARS_PER_TOKEN)
}
+89
View File
@@ -0,0 +1,89 @@
import type { Connection, Effort, ModelSpec } from "../config/schema.ts"
// Provider-neutral conversation model. Every dialect converts to and from this.
export type TextPart = { type: "text"; text: string }
export type ImagePart = { type: "image"; mime: string; data: string /* base64 */ }
/** `signature` / `opaque` carry what a provider needs echoed back on the next turn
* (Anthropic thinking signatures, Gemini thought signatures, Responses encrypted reasoning). */
export type ReasoningPart = { type: "reasoning"; text: string; signature?: string; opaque?: unknown }
/** `signature`: Gemini's thought signature, which must travel back with the call. */
export type ToolCallPart = { type: "tool_call"; id: string; name: string; args: string /* raw JSON */; signature?: string }
export type Message =
/** `turnId`: the turn a prompt started, kept with it in the store for
* `_lembas/session/history`; never sent to a model. */
| { role: "user"; parts: (TextPart | ImagePart)[]; turnId?: string }
| { role: "assistant"; parts: (TextPart | ReasoningPart | ToolCallPart)[] }
| { role: "tool"; callId: string; name: string; content: string; isError?: boolean }
export interface ToolSpec {
name: string
description: string
/** JSON Schema for the arguments object. */
parameters: Record<string, unknown>
}
export interface Usage {
input: number
output: number
reasoning?: number
cached?: number
/** True when the endpoint reported nothing and these are estimates. */
estimated?: boolean
}
export type StreamEvent =
| { type: "text"; text: string }
| { type: "reasoning"; text: string }
/** Partial tool-call progress, for the UI. */
| { type: "tool_call_delta"; index: number; name?: string; argsDelta: string }
| { type: "usage"; usage: Usage }
/** How far the server is through reading the prompt, before the reply starts (llama.cpp's
* return_progress): tokens in all, taken from its cache, and read so far; ms spent reading. */
| { type: "progress"; total: number; cache: number; processed: number; ms: number }
| { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> }
/** Something worth telling the user that is not an error, e.g. a retry. */
| { type: "notice"; message: string }
export interface ChatRequest {
system: string
messages: Message[]
tools: ToolSpec[]
/** null = off */
effort?: Effort | null
signal?: AbortSignal
}
export interface ResolvedModel {
/** `connection/model` */
ref: string
connectionName: string
connection: Connection
id: string
spec: ModelSpec
}
export interface DiscoveredModel {
id: string
context?: number
}
export interface Client {
stream(req: ChatRequest): AsyncGenerator<StreamEvent>
listModels(): Promise<DiscoveredModel[]>
}
export class ProviderError extends Error {
constructor(
message: string,
readonly status?: number,
readonly body?: string,
/** Nothing of the model's reply had been passed on yet, so a retry cannot duplicate output. */
readonly beforeOutput = status !== undefined,
/** The server could not be reached at all (refused, no route, no answer): another may be. */
readonly unreachable = false,
) {
super(message)
}
}
+55
View File
@@ -0,0 +1,55 @@
// /reload: start LLeMbas CLI again in place — the binary as it is on disk now (an update installed
// while it ran), every config file read afresh — back on the same session. The process replaces
// itself (execv), so the PID, the terminal and the shell's job stay what they were; where execv
// is not to be had, a child takes over and this process waits for it.
import { fstatSync, readdirSync } from "node:fs"
/** The command line that starts this program again: the compiled binary, or bun and the script. */
export function selfCommand(args: string[]): string[] {
const compiled = Bun.main.startsWith("/$bunfs/")
return compiled ? [process.execPath, ...args] : [process.execPath, Bun.main, ...args]
}
function execv(argv: string[]): Error {
try {
const { dlopen, FFIType, ptr } = require("bun:ffi") as typeof import("bun:ffi")
const libc = dlopen("libc.so.6", {
execv: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
})
// Everything above stdio closes on exec: an open database, an MCP server's pipe would otherwise
// live on, unknown, in the new program. Marked rather than closed, so a failed exec leaves this
// process whole for the fallback.
const F_SETFD = 2
const FD_CLOEXEC = 1
// Every open descriptor, however high: /proc/self/fd lists them.
let fds: number[]
try {
fds = readdirSync("/proc/self/fd").map(Number).filter((n) => n >= 3)
} catch {
fds = Array.from({ length: 4093 }, (_, i) => i + 3)
}
for (const fd of fds) {
try {
fstatSync(fd)
libc.symbols.fcntl(fd, F_SETFD, FD_CLOEXEC)
} catch {}
}
const strings = argv.map((a) => Buffer.from(`${a}\0`))
const table = new BigUint64Array(strings.length + 1)
strings.forEach((s, i) => (table[i] = BigInt(ptr(s))))
libc.symbols.execv(ptr(strings[0]!), ptr(table))
// execv returns only when it failed.
return new Error("execv failed")
} catch (e) {
return e as Error
}
}
/** Never returns: the new program runs, in this process or a child this one waits for. */
export async function restart(args: string[]): Promise<never> {
const argv = selfCommand(args)
execv(argv)
const child = Bun.spawn(argv, { stdio: ["inherit", "inherit", "inherit"] })
process.exit(await child.exited)
}
+47
View File
@@ -0,0 +1,47 @@
import { request } from "../provider/http.ts"
import { SearchError, type SearchConfig, type SearchResult } from "./index.ts"
// DuckDuckGo's HTML page — no key, no API, and it may decide it is talking to a bot. When it does,
// that is said, not returned as "no results". The results are links with class result__a (whose
// href is either the page itself or a //duckduckgo.com/l/?uddg=<page> redirect) and snippets with
// class result__snippet.
const UA = "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
const SAFE = { strict: "1", moderate: "-1", off: "-2" } as const
export function parseDdg(html: string): SearchResult[] {
if (/anomaly-modal|detected unusual activity|challenge-form|g-recaptcha/i.test(html)) throw new SearchError("DuckDuckGo answered with a bot check; try again later, or configure SearXNG")
const out: SearchResult[] = []
const blocks = html.split(/<div[^>]+class="[^"]*\bresult\b[^"]*"/).slice(1)
for (const b of blocks) {
if (/result--ad\b/.test(b.slice(0, 200))) continue
const a = /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/.exec(b)
if (!a) continue
let url = decode(a[1]!)
const redirect = /[?&]uddg=([^&]+)/.exec(url)
if (redirect) url = decodeURIComponent(redirect[1]!)
if (url.startsWith("//")) url = "https:" + url
if (!/^https?:\/\//.test(url) || /duckduckgo\.com\/y\.js/.test(url)) continue
const snip = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/.exec(b)
out.push({ title: text(a[2]!), url, snippet: snip ? text(snip[1]!) : "" })
}
return out
}
function decode(s: string): string {
return s.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#x27;|&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
}
function text(html: string): string {
return decode(html.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim()
}
export async function ddgSearch(query: string, c: SearchConfig, limit: number, signal: AbortSignal): Promise<SearchResult[]> {
const form = new URLSearchParams({ q: query, kl: c.ddg?.region ?? "wt-wt", kp: SAFE[c.ddg?.safe ?? "moderate"] })
const res = await request(
"https://html.duckduckgo.com/html/",
{ method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", "user-agent": UA }, body: form.toString(), signal, timeoutMs: 20_000 },
"duckduckgo",
)
if (res.status === 202) throw new SearchError("DuckDuckGo is rate-limiting (202); try again later, or configure SearXNG")
return parseDdg(await res.text()).slice(0, limit)
}
+118
View File
@@ -0,0 +1,118 @@
// Reading a web page for the model: HTML turned into markdown (turndown), text as it is, others
// described. Capped in size; long pages are read in parts with an offset.
import TurndownService from "turndown"
import { lookup } from "node:dns/promises"
import { request } from "../provider/http.ts"
const MAX_BYTES = 5_000_000
const UA = "Mozilla/5.0 (X11; Linux x86_64) LLeMbas CLI"
const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-" })
turndown.remove(["script", "style", "noscript", "iframe", "svg", "form", "nav", "footer"] as never)
/** The first <tag …>…</tag> in the HTML, found by searching, not by a lazy regex: on a page of
* thousands of unclosed `<main` a regex is quadratic and freezes the program for minutes. */
function element(html: string, lower: string, tag: string): string | undefined {
const open = lower.indexOf(`<${tag}`)
if (open < 0) return undefined
const close = lower.indexOf(`</${tag}>`, open)
return close < 0 ? undefined : html.slice(open, close + tag.length + 3)
}
/** HTML as markdown, the way web_fetch reads a page (scripts, styles and navigation dropped). */
export function htmlToMarkdown(html: string): string {
const lower = html.toLowerCase()
const body = element(html, lower, "main") ?? element(html, lower, "article") ?? html
return turndown.turndown(body).replace(/\n{3,}/g, "\n\n").trim()
}
export class PrivateAddressError extends Error {}
/** Whether a host is, or resolves to, an address on this machine or the local network. */
export async function resolvesPrivate(host: string): Promise<boolean> {
if (isPrivateHost(host)) return true
const h = host.replace(/^\[|\]$/g, "")
if (/^[\d.]+$/.test(h) || h.includes(":")) return false
try {
const all = await lookup(h, { all: true })
return all.some((a) => isPrivateHost(a.address))
} catch {
return false
}
}
/** A page, following at most five redirects by hand: each one is checked again, so a public page
* cannot send the fetch on to a local service (`allowPrivate`: the host the user approved). */
export async function fetchPage(url: string, signal: AbortSignal, o: { allowPrivate?: string | true } = {}): Promise<{ title?: string; text: string; type: string; status: number }> {
let at = url
let res: Response | undefined
for (let hop = 0; hop <= 5; hop++) {
const u = new URL(at)
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error(`a redirect to ${u.protocol} — not followed`)
if (o.allowPrivate !== true && u.hostname !== o.allowPrivate && (await resolvesPrivate(u.hostname)))
throw new PrivateAddressError(`${hop ? `the page redirected to ${u.host}, which` : `${u.host}`} is on this machine or the local network — not fetched. Fetch it by a local name or address (that is asked first), if the user wants it.`)
res = await request(at, { headers: { "user-agent": UA, accept: "text/html,application/xhtml+xml,text/plain,application/json;q=0.9,*/*;q=0.5" }, signal, timeoutMs: 30_000, redirect: "manual" }, u.host)
const next = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null
if (!next) break
await res.body?.cancel().catch(() => {})
at = new URL(next, at).toString()
if (hop === 5) throw new Error("more than five redirects")
}
res = res!
const type = (res.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase()
const bytes = await readCapped(res)
const raw = new TextDecoder().decode(bytes)
if (type.includes("html") || (!type && /<html|<body/i.test(raw.slice(0, 2000)))) {
const lower = raw.toLowerCase()
const t = element(raw, lower, "title")
const title = t?.slice(t.indexOf(">") + 1, t.lastIndexOf("<")).replace(/\s+/g, " ").trim()
return { title, text: htmlToMarkdown(raw), type: type || "text/html", status: res.status }
}
if (type.startsWith("text/") || type.includes("json") || type.includes("xml") || type.includes("javascript")) return { text: raw, type, status: res.status }
return { text: `(${type || "unknown type"}, ${bytes.length} bytes — not a page that can be shown as text)`, type, status: res.status }
}
async function readCapped(res: Response): Promise<Uint8Array> {
if (!res.body) return new Uint8Array()
const chunks: Uint8Array[] = []
let size = 0
for await (const c of res.body) {
chunks.push(c)
size += c.length
if (size > MAX_BYTES) break
}
const out = new Uint8Array(Math.min(size, MAX_BYTES))
let at = 0
for (const c of chunks) {
const take = Math.min(c.length, out.length - at)
out.set(c.subarray(0, take), at)
at += take
if (at >= out.length) break
}
return out
}
/** Hosts on this machine or the local network: fetching them is asked about, not assumed. */
export function isPrivateHost(host: string): boolean {
let h = host.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "")
if (h === "localhost" || h.endsWith(".localhost") || (!h.includes(".") && !h.includes(":"))) return true
if (/\.(lan|local|localdomain|internal|home|home\.arpa|intranet|corp)$/.test(h)) return true
if (h.includes(":")) {
// IPv4 inside IPv6 (::ffff:127.0.0.1, or as the URL parser writes it, ::ffff:7f00:1).
const mapped = /^(?:0*:)*:?ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(h) ?? null
if (mapped) return isPrivateHost(mapped[1]!)
const hex = /^(?:0*:)*:?ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(h)
if (hex) {
const a = parseInt(hex[1]!, 16)
const b = parseInt(hex[2]!, 16)
return isPrivateHost(`${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`)
}
return h === "::" || h === "::1" || /^f[cd][0-9a-f]{2}:/.test(h) || /^fe[89ab][0-9a-f]:/.test(h)
}
const v4 = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(h)
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])]
return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127)
}
return false
}
+47
View File
@@ -0,0 +1,47 @@
import { request, tlsFor } from "../provider/http.ts"
import { SearchError, type SearchConfig, type SearchResult } from "./index.ts"
// Firecrawl, v2 first and v1 where v2 is not there (older self-hosted builds). Hosted:
// https://api.firecrawl.dev with a key. Self-hosted: its own URL and no key — a self-hosted
// Firecrawl never reads the Authorization header, so none is sent unless a key is configured.
const HOSTED = "https://api.firecrawl.dev"
const noV2 = new Set<string>()
function base(c: SearchConfig) {
return (c.firecrawl?.base_url ?? HOSTED).replace(/\/+$/, "")
}
async function call(c: SearchConfig, path: string, body: unknown, signal: AbortSignal, what: string): Promise<any> {
const f = c.firecrawl
if (!f) throw new SearchError("not configured")
const b = base(c)
if (b === HOSTED && !f.api_key) throw new SearchError("api.firecrawl.dev needs api_key (a self-hosted Firecrawl does not)")
const headers: Record<string, string> = { "content-type": "application/json", ...(f.api_key ? { authorization: `Bearer ${f.api_key}` } : {}) }
for (const v of noV2.has(b) ? ["v1"] : ["v2", "v1"]) {
try {
const res = await request(`${b}/${v}/${path}`, { method: "POST", headers, body: JSON.stringify(body), signal, timeoutMs: 60_000, tls: tlsFor(f) }, what)
const j = (await res.json()) as any
if (j?.success === false) throw new SearchError(j.error ?? "the request failed")
return j
} catch (e) {
if (v === "v2" && (e as { status?: number }).status === 404) {
noV2.add(b)
continue
}
throw e
}
}
}
export async function firecrawlSearch(query: string, c: SearchConfig, limit: number, signal: AbortSignal): Promise<SearchResult[]> {
const j = await call(c, "search", { query, limit }, signal, "firecrawl search")
// v2: data.web[]; v1: data[]
const list: any[] = Array.isArray(j?.data) ? j.data : (j?.data?.web ?? [])
return list.filter((r) => r?.url).map((r) => ({ title: r.title ?? r.url, url: r.url, snippet: (r.description ?? r.snippet ?? "").trim() }))
}
export async function firecrawlScrape(url: string, c: SearchConfig, signal: AbortSignal): Promise<{ title?: string; markdown: string }> {
const j = await call(c, "scrape", { url, formats: ["markdown"], onlyMainContent: true }, signal, "firecrawl scrape")
const d = j?.data ?? {}
return { title: d.metadata?.title, markdown: d.markdown ?? "" }
}
+54
View File
@@ -0,0 +1,54 @@
// Web search: a LLeMbas instance's own (webui), SearXNG, Firecrawl (its hosted API with a key, or a
// self-hosted instance without one) and DuckDuckGo's HTML page, tried in order until one answers.
// LLeMbas has the last three.
import type { Config } from "../config/schema.ts"
import { ProviderError } from "../provider/types.ts"
import { ddgSearch } from "./ddg.ts"
import { firecrawlSearch } from "./firecrawl.ts"
import { searxngSearch } from "./searxng.ts"
import { webuiSearch } from "./webui.ts"
export interface SearchResult {
title: string
url: string
snippet: string
}
export type SearchConfig = NonNullable<Config["search"]>
export type Provider = "webui" | "searxng" | "firecrawl" | "ddg"
export class SearchError extends Error {}
/** The providers to try: as configured, else every configured one, then DuckDuckGo. A listed
* service that is not configured (or was turned off because its key could not be read) is skipped. */
export function searchOrder(c: SearchConfig): Provider[] {
if (c.order?.length) return c.order.filter((p) => p === "ddg" || c[p] !== undefined)
const out: Provider[] = []
// The instance first: it is where the user set search up once for every machine.
if (c.webui) out.push("webui")
if (c.searxng) out.push("searxng")
if (c.firecrawl) out.push("firecrawl")
out.push("ddg")
return out
}
export async function search(query: string, c: SearchConfig, signal: AbortSignal, limit = c.max_results ?? 8): Promise<{ provider: Provider; results: SearchResult[]; failed: string[] }> {
const failed: string[] = []
for (const p of searchOrder(c)) {
try {
const results =
p === "webui"
? await webuiSearch(query, c, limit, signal)
: p === "searxng"
? await searxngSearch(query, c, limit, signal)
: p === "firecrawl"
? await firecrawlSearch(query, c, limit, signal)
: await ddgSearch(query, c, limit, signal)
return { provider: p, results: results.slice(0, limit), failed }
} catch (e) {
if (signal.aborted) throw e
failed.push(`${p}: ${e instanceof ProviderError || e instanceof SearchError ? e.message : (e as Error).message}`)
}
}
throw new SearchError(`every search provider failed — ${failed.join("; ")}`)
}
+24
View File
@@ -0,0 +1,24 @@
import { request, tlsFor } from "../provider/http.ts"
import { SearchError, type SearchConfig, type SearchResult } from "./index.ts"
// GET /search?format=json. A 403 means the instance has JSON output switched off
// (search.formats in its settings.yml).
export async function searxngSearch(query: string, c: SearchConfig, limit: number, signal: AbortSignal): Promise<SearchResult[]> {
const s = c.searxng
if (!s) throw new SearchError("not configured")
const url = new URL(s.base_url.replace(/\/+$/, "") + "/search")
url.searchParams.set("q", query)
url.searchParams.set("format", "json")
if (s.categories) url.searchParams.set("categories", s.categories)
if (s.language) url.searchParams.set("language", s.language)
const headers: Record<string, string> = s.api_key ? { authorization: `Bearer ${s.api_key}` } : {}
let res: Response
try {
res = await request(url.toString(), { headers, signal, timeoutMs: 20_000, tls: tlsFor(s) }, "searxng")
} catch (e) {
if ((e as { status?: number }).status === 403) throw new SearchError("JSON output is disabled on this SearXNG (add json to search.formats in its settings.yml)")
throw e
}
const j = (await res.json()) as { results?: { title?: string; url?: string; content?: string }[] }
return (j.results ?? []).filter((r) => r.url).slice(0, limit).map((r) => ({ title: r.title ?? r.url!, url: r.url!, snippet: (r.content ?? "").trim() }))
}
+41
View File
@@ -0,0 +1,41 @@
// Web search and page fetch through a LLeMbas instance: its provider, its settings and its
// keys, so a machine logged in to it needs none of its own. `search.webui` is filled in by the
// loader from the webui connection — the address and this machine's key — and never written by hand.
import { joinUrl, request, tlsFor } from "../provider/http.ts"
import { SearchError, type SearchConfig, type SearchResult } from "./index.ts"
export interface WebuiSearch {
connection?: string
base_url?: string
api_key?: string
tls?: { ca?: string; insecure?: boolean }
}
function target(c: SearchConfig): Required<Pick<WebuiSearch, "base_url">> & WebuiSearch {
const w = c.webui as WebuiSearch | undefined
if (!w?.base_url) throw new SearchError("not configured (a webui connection: lembas login)")
return w as Required<Pick<WebuiSearch, "base_url">> & WebuiSearch
}
async function post(w: WebuiSearch & { base_url: string }, path: string, body: unknown, signal: AbortSignal, what: string): Promise<any> {
const headers: Record<string, string> = { "content-type": "application/json", ...(w.api_key ? { authorization: `Bearer ${w.api_key}` } : {}) }
try {
const res = await request(joinUrl(w.base_url, path), { method: "POST", headers, body: JSON.stringify(body), signal, timeoutMs: 60_000, tls: tlsFor(w) }, what)
return await res.json()
} catch (e) {
// 404: the instance has it switched off for this account, or does not have the endpoint.
if ((e as { status?: number }).status === 404) throw new SearchError(`${new URL(w.base_url).host} does not offer this to this account`)
throw e
}
}
export async function webuiSearch(query: string, c: SearchConfig, limit: number, signal: AbortSignal): Promise<SearchResult[]> {
const j = await post(target(c), "api/v1/search", { query, max_results: limit }, signal, "webui search")
const list: any[] = Array.isArray(j?.results) ? j.results : []
return list.filter((r) => r?.url).map((r) => ({ title: String(r.title ?? r.url), url: String(r.url), snippet: String(r.snippet ?? "").trim() }))
}
export async function webuiFetch(url: string, c: SearchConfig, signal: AbortSignal): Promise<{ title?: string; text: string }> {
const j = await post(target(c), "api/v1/fetch", { url }, signal, "webui fetch")
return { title: j?.title || undefined, text: String(j?.text ?? "") }
}
+231
View File
@@ -0,0 +1,231 @@
// `lembas service`: the link as a background service, after Hermes's gateway — a
// systemd *user* unit, restarted when it fails, its output in the journal, one copy at a time.
//
// lembas service install write ~/.config/systemd/user/lembas.service and start it
// lembas service uninstall stop it and remove the unit
// lembas service status what the running link says (and systemd's view)
// lembas service logs its journal
// lembas service run what the unit runs: the link, in the foreground
//
// It runs as the user, never as root, and listens on nothing. To keep it running while nobody is
// logged in, the user needs lingering (`loginctl enable-linger`), which `install` says rather than
// does: on most systems that is a decision for whoever administers the machine.
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { paths } from "./config/paths.ts"
import { LinkError, limitsFromConfig, runLink, type LinkStatus } from "./acp/link.ts"
import { Hub } from "./acp/hub.ts"
export const UNIT = "lembas.service"
const unitDir = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "systemd", "user")
export const unitFile = () => join(unitDir(), UNIT)
const statusFile = () => join(paths.state, "service.json")
const lockFile = () => join(paths.state, "service.lock")
/** The command the unit starts: this binary, or `bun <cli>` when run from source. */
export function execStart(): string {
const exe = process.execPath
const script = process.argv[1] ?? ""
const fromSource = /(^|\/)bun$/.test(exe) && script.endsWith(".ts")
const q = (s: string) => (/[\s"'\\]/.test(s) ? `"${s.replace(/(["\\])/g, "\\$1")}"` : s)
return fromSource ? `${q(exe)} ${q(script)} service run` : `${q(exe)} service run`
}
export function unitText(command = execStart()): string {
return `[Unit]
Description=LLeMbas CLI: work from a LLeMbas instance (lembas service)
Documentation=https://llembas.eu
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=${command}
Restart=on-failure
RestartSec=10
# Stopping: the service ends its own work in seconds; a shell left behind (a terminal
# opened from the web UI is an interactive bash, which ignores SIGTERM) is hung up on, and nothing
# holds a stop for the default 90 s.
KillMode=mixed
SendSIGHUP=yes
TimeoutStopSec=15
# Nothing here needs more than the user already has.
NoNewPrivileges=yes
[Install]
WantedBy=default.target
`
}
export type Systemctl = (...args: string[]) => { ok: boolean; out: string }
function systemctl(...args: string[]): { ok: boolean; out: string } {
const r = Bun.spawnSync(["systemctl", "--user", ...args], { stdout: "pipe", stderr: "pipe" })
return { ok: r.exitCode === 0, out: (r.stdout.toString() + r.stderr.toString()).trim() }
}
/** The installed unit, if any: what `service uninstall` and `lembas uninstall` stop. */
export const installedUnits = () => [unitFile()].filter((f) => existsSync(f))
/** Stop, disable and remove the unit, where installed. Before the binary goes too: a unit left
* running sees its binary vanish and restart-loops on 203/EXEC every 10 s. A systemctl
* that fails (no user manager — a container, an SSH session without lingering) does not stop the
* files going: nothing is left to start it again. */
export function removeUnits(run: Systemctl = systemctl): string[] {
const lines: string[] = []
for (const file of installedUnits()) {
run("disable", "--now", UNIT)
rmSync(file, { force: true })
lines.push(`stopped, and removed ${file}`)
}
if (lines.length) run("daemon-reload")
return lines
}
export function install(): string[] {
mkdirSync(unitDir(), { recursive: true })
const lines: string[] = []
writeFileSync(unitFile(), unitText())
lines.push(`wrote ${unitFile()}`)
const reload = systemctl("daemon-reload")
if (!reload.ok) return [...lines, `systemctl --user daemon-reload failed: ${reload.out}`, "Start it by hand: lembas service run"]
const start = systemctl("enable", "--now", UNIT)
lines.push(start.ok ? "enabled and started" : `could not start it: ${start.out}`)
const user = process.env.USER ?? ""
const linger = Bun.spawnSync(["loginctl", "show-user", user, "--property=Linger"], { stdout: "pipe", stderr: "pipe" }).stdout.toString()
if (!/Linger=yes/.test(linger)) lines.push(`It stops when you log out. To keep it running: loginctl enable-linger ${user}`)
return lines
}
export function uninstall(): string[] {
const lines = removeUnits()
if (!lines.some((l) => l.includes(unitFile()))) lines.unshift("no service installed")
rmSync(statusFile(), { force: true })
return lines
}
export function status(): string[] {
const lines: string[] = []
try {
const s = JSON.parse(readFileSync(statusFile(), "utf8")) as LinkStatus & { pid: number }
let alive = false
try {
process.kill(s.pid, 0)
alive = true
} catch {}
lines.push(alive ? `${s.state} — ${s.instance} since ${s.since}${s.detail ? ` (${s.detail})` : ""}` : `not running (last: ${s.state}${s.detail ? `, ${s.detail}` : ""})`)
} catch {
lines.push("not running")
}
if (existsSync(unitFile())) lines.push(`systemd: ${systemctl("is-active", UNIT).out || "unknown"}`)
else lines.push("no systemd unit installed (lembas service install)")
return lines
}
export function logs(follow: boolean): number {
const r = Bun.spawnSync(["journalctl", "--user", "-u", UNIT, "-n", "200", ...(follow ? ["-f"] : [])], { stdout: "inherit", stderr: "inherit" })
return r.exitCode ?? 1
}
/** One copy at a time: a second `service run` refuses while the first holds the lock. */
function takeLock(): () => void {
mkdirSync(paths.state, { recursive: true })
try {
const held = Number(readFileSync(lockFile(), "utf8"))
if (held) {
process.kill(held, 0)
throw new LinkError(`already running (pid ${held})`)
}
} catch (e) {
if (e instanceof LinkError) throw e
}
const fd = openSync(lockFile(), "w")
writeFileSync(fd, String(process.pid))
closeSync(fd)
return () => rmSync(lockFile(), { force: true })
}
/** An installed unit from an older release is brought up to this one's text, so a change to
* how it stops reaches machines that installed it before. It takes effect at the next start. */
function refreshUnit() {
try {
if (!existsSync(unitFile())) return
const want = unitText()
if (readFileSync(unitFile(), "utf8") === want) return
writeFileSync(unitFile(), want)
systemctl("daemon-reload")
console.log(`brought ${unitFile()} up to this release`)
} catch {}
}
/** Exit status for "the binary was updated; start me again" — non-zero, so Restart=on-failure does. */
export const RESTART_FOR_UPDATE = 75
export async function run(instance?: string): Promise<number> {
const release = takeLock()
const stop = new AbortController()
for (const sig of ["SIGTERM", "SIGINT"] as const) process.once(sig, () => stop.abort())
refreshUnit()
// The hub: the terminals on this machine share their sessions through the service. A
// terminal that holds the link itself for /remote keeps it until it closes; one link per device,
// so the service waits for it rather than knocking it off.
const hub = new Hub({ service: true, limits: limitsFromConfig() })
let said = false
while (!stop.signal.aborted && !(await hub.listen())) {
if (!said) console.log("a terminal here holds the link (/remote); waiting for it to close")
said = true
await new Promise<void>((r) => {
const t = setTimeout(r, 15_000)
stop.signal.addEventListener("abort", () => (clearTimeout(t), r()), { once: true })
})
}
// Updated on disk: a terminal installs a new release, and this process goes on running
// the old one. It starts again on the new one only when nothing works here — no session
// working, none a terminal shares in the middle of a turn — so an update never cuts a reply off.
// Sessions are in the store; the terminals reconnect by themselves.
let updated = false
const binary = process.execPath
const fromSource = /(^|\/)bun$/.test(binary)
const builtAt = fromSource ? 0 : mtime(binary)
const watch = fromSource
? undefined
: setInterval(() => {
if (!builtAt || mtime(binary) === builtAt || hub.busy()) return
updated = true
console.log("a new release is installed and nothing is working: starting again on it")
stop.abort()
}, 30_000)
watch?.unref?.()
try {
const end = await runLink({
instance,
hub,
signal: stop.signal,
onStatus: (s) => {
writeFileSync(statusFile(), JSON.stringify({ ...s, pid: process.pid }, null, 2))
console.log(`${s.state}${s.detail ? `: ${s.detail}` : ""}`)
},
})
// Refused (token revoked): exit cleanly so systemd does not knock on a closed door for ever.
void end
return updated ? RESTART_FOR_UPDATE : 0
} catch (e) {
console.error(`lembas service: ${(e as Error).message}`)
// A configuration that cannot work is not fixed by restarting: exit 0 so Restart=on-failure
// leaves it, and the journal says why.
return e instanceof LinkError ? 0 : 1
} finally {
clearInterval(watch)
hub.close()
release()
}
}
function mtime(file: string): number {
try {
return statSync(file).mtimeMs
} catch {
return 0
}
}
+33
View File
@@ -0,0 +1,33 @@
// Whether a subagent may run on a model beside the session's own, judged by what its server can do
// at once — the harness spec's capacity rule, after LLeMbas services/helpers.py (© Jaroslav Beneš,
// MIT). Both flags name the restrictive state and default off, so a configuration that
// never sets them works exactly as before.
//
// - A connection that holds one model at a time (llama-swap in front of one GPU): a subagent on
// another of its models unloads the session's model, and the session's cached prompt with it,
// while its turn waits. The session's own model may still be its own subagent there.
// - A model that serves one request at a time: a subagent on it takes the server's only slot, so
// the session's cached prompt is pushed out and its next step re-reads the whole conversation —
// minutes, on a long session with a small GPU.
import type { ResolvedModel } from "../provider/types.ts"
export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel): string {
if (helper.ref === main.ref) {
if (helper.spec.single_session)
return `${helper.ref} serves one request at a time (single_session), so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.`
return ""
}
const shared = groupOf(main)
if (shared !== undefined && shared === groupOf(helper)) {
const where = main.spec.group !== undefined ? `${main.ref}'s server (group ${main.spec.group})` : main.connectionName
return `${where} holds one model at a time (${main.spec.group !== undefined ? "group" : "one_model_at_a_time"}), so a subagent on ${helper.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.`
}
return ""
}
/** Which one-model-at-a-time server a model is on: its own `group`, else its connection when the
* connection sets one_model_at_a_time, else none. The spec's capacity.group. */
function groupOf(m: ResolvedModel): string | undefined {
if (m.spec.group !== undefined) return `group:${m.spec.group}`
return m.connection.one_model_at_a_time ? `connection:${m.connectionName}` : undefined
}
+187
View File
@@ -0,0 +1,187 @@
// The slash commands a prompt may start with anywhere, not only in the TUI: what the web UI
// offers in its composer (`available_commands_update`, `_lembas/commands`) and what a prompt sent
// over ACP that starts with `/name` turns into. The set is the shared one — custom commands
// (`.agent/commands`, global), skills (`/skill-name`), and the built-ins that need no screen of
// their own: compact, plan, undo, review, changelog, init, continue. What only the TUI can do (the
// sessions picker, settings, theme, login, quit…) is never listed and never expanded here.
//
// The TUI runs the same builders (review, changelog, init) and the same rules (a built-in name wins
// over a custom command's, a custom command's over a skill's; @files come from what was typed, never
// from a command's own text), so a command does the same thing whichever keyboard typed it.
import { existsSync } from "node:fs"
import type { App } from "../app.ts"
import { MODES, type Mode } from "../config/schema.ts"
import { git } from "../git/run.ts"
import { customCommands, expandCommand } from "../project/commands.ts"
import { fill, promptText } from "../prompt/assemble.ts"
import { skillMessage, slug, type Skill } from "../skill/index.ts"
import { CONTINUE_PROMPT } from "./engine.ts"
export type CommandKind = "custom" | "skill" | "builtin"
/** One command as ACP's `availableCommands` lists it. */
export interface CommandInfo {
name: string
description: string
input?: { hint: string }
_meta: { lembas: { kind: CommandKind } }
}
/** Every name the TUI's own commands take, aliases included (tui/commands.ts COMMANDS; a test keeps
* the two equal). A custom command or a skill of one of these names is not a command — in the TUI
* the built-in wins — so it is not one here either, even where the built-in is not offered. */
export const TUI_COMMAND_NAMES = [
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
"sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
"icons", "trust", "reload", "restart", "quit", "exit", "q",
] as const
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
export const SHARED_BUILTINS: { name: string; description: string; hint?: string }[] = [
{ name: "compact", description: "summarise the conversation to free context" },
{ name: "plan", description: "plan mode — read, investigate, write a plan for approval; with a task, start on it", hint: "task" },
{ name: "undo", description: "take back the last prompt: its file changes and its conversation" },
{ name: "review", description: "a read-only review of this session's changes (or since the last commit, or against a branch)", hint: "head | <branch>" },
{ name: "changelog", description: "update CHANGELOG.md's Unreleased section from the commits since the last tag", hint: "since" },
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
]
const reserved = new Set<string>(TUI_COMMAND_NAMES)
/** Where the commands come from: a trusted project's `.agent` (or none), and the skills. */
export interface CommandSources {
projectDir?: string
skills: () => Skill[]
}
export function sourcesOf(app: App): CommandSources {
return { projectDir: app.trusted && existsSync(app.project.dir) ? app.project.dir : undefined, skills: () => app.skills() }
}
/** The shared set, built-ins first, then custom commands, then skills. */
export function commandSet(src: CommandSources): CommandInfo[] {
const out: CommandInfo[] = SHARED_BUILTINS.map((b) => ({ name: b.name, description: b.description, ...(b.hint ? { input: { hint: b.hint } } : {}), _meta: { lembas: { kind: "builtin" as const } } }))
const custom = customCommands(src.projectDir).filter((c) => !reserved.has(c.name))
for (const c of custom) out.push({ name: c.name, description: c.description, ...(c.body.includes("$") ? { input: { hint: "arguments" } } : {}), _meta: { lembas: { kind: "custom" } } })
const taken = new Set(custom.map((c) => c.name))
for (const s of src.skills()) {
const name = slug(s.name)
if (reserved.has(name) || taken.has(name)) continue
taken.add(name)
out.push({ name, description: s.description.slice(0, 200), input: { hint: "instruction" }, _meta: { lembas: { kind: "skill" } } })
}
return out
}
/** What a prompt starting with a command turns into. */
export type Expanded =
/** A prompt to send: `prompt` to the model, `shown` in the transcript, @files read from
* `attachFrom` only; a model and mode for this one prompt. */
| { kind: "prompt"; name: string; prompt: string; shown: string; attachFrom: string; model?: string; mode?: Mode }
/** A built-in that is not a prompt (compact, undo; plan without a task). */
| { kind: "action"; name: "compact" | "undo" | "plan"; arg: string }
/** Nothing to send, and why (a review with no changes). */
| { kind: "message"; name: string; text: string }
/** `/review [head | <branch>]`: the change, as a prompt for a read-only review — or why there is none. */
export function reviewPrompt(app: App, arg: string): { prompt: string } | { message: string } {
const root = app.project.gitRoot
let diff: string | undefined
let scope: string
if (arg && arg !== "head") {
if (!root) return { message: "not a git repository" }
const r = git(root, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", `${arg}...HEAD`])
if (r.code !== 0) return { message: `git diff ${arg}...HEAD: ${r.err}` }
diff = r.out
scope = `The change is this branch against ${arg}.`
} else if (arg === "head") {
if (!root) return { message: "not a git repository" }
diff = git(root, ["diff", "--no-color", "--no-ext-diff", "--no-textconv", "HEAD"]).out
scope = "The change is everything not yet committed."
} else {
diff = app.turns.diff()
scope = "The change is what this session did."
}
if (!diff?.trim()) return { message: "nothing to review — no changes" + (arg ? "" : " in this session (/review head: since the last commit)") }
const clipped = diff.length > 60_000 ? diff.slice(0, 60_000) + "\n[… diff cut at 60 000 characters]" : diff
return { prompt: fill(promptText("tasks/review.md"), { scope, diff: clipped }) }
}
/** `/changelog [since]`: the commits since the last tag, as a prompt to update the Unreleased section. */
export function changelogPrompt(app: App, arg: string): { prompt: string } | { message: string } {
const git0 = app.project.gitRoot
if (!git0) return { message: "not a git repository" }
// The project may be one directory of a larger repository: its commits, its files.
const root = app.project.root
const only = root === git0 ? [] : ["--", "."]
const since = arg.trim() || git(root, ["describe", "--tags", "--abbrev=0"]).out
const range = since ? `${since}..HEAD` : "HEAD"
const log = git(root, ["log", "--no-merges", "--format=- %s%n%w(0,2,2)%b", range, ...only]).out.replace(/\n{2,}/g, "\n").trim()
if (!log) return { message: since ? `no commits since ${since}` : "no commits yet" }
const stat = git(root, ["diff", "--stat", since ? since : git(root, ["rev-list", "--max-parents=0", "HEAD"]).out.split("\n")[0]!, "HEAD", ...only]).out
return {
prompt: fill(promptText("tasks/changelog.md"), {
scope: since ? `Unreleased since ${since}.` : "Nothing has been released yet.",
log: log.slice(0, 20_000),
stat: stat.slice(-6_000),
}),
}
}
/** `/init [focus]`: make or update AGENTS.md. */
export function initPrompt(app: App, arg: string): string {
return fill(promptText("tasks/init.md"), {
root: app.project.root,
focus: arg.trim() ? `What the user wants it to focus on: ${arg.trim()}` : "",
}).replace(/\n{3,}/g, "\n\n")
}
/** A prompt's text as a command of the shared set, or undefined when it is not one — then it goes
* to the model as it is, `/` and all (a path, a regex, a command this machine does not have). */
export function expandSlash(app: App, text: string): Expanded | undefined {
const t = text.trim()
const m = /^\/([a-z0-9_:-]+)(?:\s+([\s\S]*))?$/i.exec(t)
if (!m) return undefined
const name = m[1]!.toLowerCase()
const arg = (m[2] ?? "").trim()
switch (name) {
case "compact":
case "undo":
return { kind: "action", name, arg }
case "plan":
return arg ? { kind: "prompt", name, prompt: arg, shown: arg, attachFrom: arg, mode: "plan" } : { kind: "action", name, arg }
case "continue":
return { kind: "prompt", name, prompt: CONTINUE_PROMPT, shown: "↻ continue", attachFrom: "" }
case "init":
// The prompt is ours; only what was typed after the name may attach files.
return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg }
case "review": {
const r = reviewPrompt(app, arg)
// The diff is somebody else's text: an @path in it must not attach a file.
return "message" in r ? { kind: "message", name, text: r.message } : { kind: "prompt", name, prompt: r.prompt, shown: t, attachFrom: "", mode: "plan" }
}
case "changelog": {
const r = changelogPrompt(app, arg)
return "message" in r ? { kind: "message", name, text: r.message } : { kind: "prompt", name, prompt: r.prompt, shown: t, attachFrom: "" }
}
}
if (reserved.has(name)) return undefined
const src = sourcesOf(app)
const cc = customCommands(src.projectDir).find((c) => c.name === name)
if (cc) {
// A project's command may make the mode stricter for its prompt, never looser; your own may.
const own = cc.mode && (MODES as readonly string[]).includes(cc.mode) ? (cc.mode as Mode) : undefined
const mode = own && cc.source === "project" ? stricter(own, app.engine.mode) : own
return { kind: "prompt", name, prompt: expandCommand(cc, arg, app.project.root, app.engine.o.perm.hardline), shown: t, attachFrom: arg, model: cc.model, ...(mode ? { mode } : {}) }
}
const sk = src.skills().find((s) => slug(s.name) === name)
if (sk) return { kind: "prompt", name, prompt: skillMessage(sk, arg), shown: t, attachFrom: arg }
return undefined
}
const RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
function stricter(a: Mode, b: Mode): Mode {
return RANK[a] > RANK[b] ? a : b
}
+62
View File
@@ -0,0 +1,62 @@
// /context: where the window goes. Estimates (≈4 characters a token) per part, scaled so they add
// up to what the server last reported when it reported anything.
import type { Engine } from "./engine.ts"
import { contextWindow } from "../provider/discover.ts"
import { estimateTokens } from "../provider/tokens.ts"
import { toSpec, type Tool } from "../tool/tool.ts"
export interface ContextRow {
label: string
tokens: number
indent?: boolean
}
export function contextBreakdown(engine: Engine, system: string, instructions: { path: string; text: string }[], git: string | undefined, tools: Tool[]) {
const rows: ContextRow[] = []
const sys = estimateTokens(system)
rows.push({ label: "system prompt", tokens: sys })
for (const f of instructions) rows.push({ label: f.path.split("/").slice(-2).join("/"), tokens: estimateTokens(f.text), indent: true })
if (git) rows.push({ label: "git state", tokens: estimateTokens(git), indent: true })
rows.push({ label: `tool definitions (${tools.length})`, tokens: estimateTokens(JSON.stringify(tools.map(toSpec))) })
const by = { user: 0, assistant: 0, reasoning: 0, calls: 0, results: 0, images: 0 }
let imageCount = 0
for (const m of engine.messages) {
if (m.role === "tool") by.results += estimateTokens(m.content)
else
for (const p of m.parts) {
if (p.type === "text") by[m.role === "user" ? "user" : "assistant"] += estimateTokens(p.text)
else if (p.type === "reasoning") by.reasoning += estimateTokens(p.text)
else if (p.type === "tool_call") by.calls += estimateTokens(p.name + p.args)
else {
imageCount++
by.images += 800 // providers bill an image at roughly this for a typical screenshot
}
}
}
rows.push({ label: "your messages and attachments", tokens: by.user })
rows.push({ label: "replies", tokens: by.assistant })
if (by.reasoning) rows.push({ label: "reasoning kept in the history", tokens: by.reasoning })
rows.push({ label: "tool calls", tokens: by.calls })
rows.push({ label: "tool results", tokens: by.results })
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
const reported = engine.usage ? engine.contextUsed() : undefined
return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref }
}
/** Plain text, for the transcript. */
export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string {
const width = 24
const total = b.window ?? Math.max(b.estimated, b.reported ?? 0)
const bar = (n: number) => {
const cells = total ? Math.max(n > 0 ? 1 : 0, Math.round((n / total) * width)) : 0
return "█".repeat(Math.min(width, cells)).padEnd(width, "·")
}
const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
const lines = [`context — ${b.model}${b.window ? `, window ${fmt(b.window)}` : ", window unknown"}`]
for (const r of b.rows) lines.push(`${r.indent ? " ↳ " : " "}${r.label.padEnd(r.indent ? 30 : 33)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : bar(r.tokens)}`)
const used = b.reported ?? b.estimated
lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${bar(used)}${b.window ? ` ${Math.round((used / b.window) * 100)}%` : ""}`)
lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since)" : " (estimated: ≈4 characters a token — no reply yet to count from)")
return lines.join("\n")
}
+769
View File
@@ -0,0 +1,769 @@
// The agent loop. Stream a reply; if it called tools, check each call against the permission
// engine (asking the user when needed), run what is allowed, feed the results back, repeat.
// It ends when a reply calls nothing, when stopped, or at the step ceiling — which is a runaway
// backstop, not a budget (LLeMbas generation._run / _wrap_up).
// The text is the harness spec's, so LLeMbas's Continue sends the same words.
import continueText from "../../harness/prompts/tasks/continue.md" with { type: "text" }
import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts"
import type { Effort, Mode } from "../config/schema.ts"
import { evaluate, shadowsDeny, type Context as PermContext, type Rule } from "../permission/evaluate.ts"
import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts"
import type { Todo } from "../tool/todo.ts"
import { splitPurpose, toSpec, ToolError, type Tool, type ToolContext, type ToolResult } from "../tool/tool.ts"
import { fill, promptText } from "../prompt/assemble.ts"
import { resolveCall } from "../tool/names.ts"
import { contextWindow } from "../provider/discover.ts"
import { summaryTurns, type Store } from "./store.ts"
import { estimateTokens } from "../provider/tokens.ts"
import { duration } from "../duration.ts"
export interface EngineOptions {
bus: Bus
asker: Asker
client: Client
model: ResolvedModel
tools: Tool[]
store?: Store
sessionId?: string
system: (mode: Mode, model: ResolvedModel) => string
mode: Mode
effort: Effort | null
perm: Omit<PermContext, "mode">
toolCtx: Omit<ToolContext, "signal">
maxSteps?: number
/** Budgets for one prompt (harness spec loop.json, from LLeMbas): time not counting waiting for
* the user, the bytes of tool output read, the tokens written. Unset: no budget. */
budget?: { wall_seconds?: number; output_bytes?: number; completion_tokens?: number }
/** Rules added by "always allow (project)" are handed here to persist. */
persistRule?: (rule: Rule) => void
compaction?: { autoAt?: number; prune?: boolean }
/** Starts a subagent for the task tool; absent in a subagent. */
spawn?: ToolContext["spawn"]
/** The model to switch to when this one's server cannot be reached (its `fallback` list), or
* undefined when there is none left to try. */
fallback?: (current: ResolvedModel, tried: Set<string>) => { model: ResolvedModel; client: Client; effort: Effort | null } | undefined
}
/** What a prompt carries besides its text. */
export interface PromptMeta {
turnId?: string
attachments?: AttachmentInfo[]
}
const MAX_PARALLEL = 4
const PRUNED = "[This output was removed to save context. Run the tool again if you need it.]"
/** What a message sent mid-turn starts with. Said, not hidden: Hermes found that a model refuses an
* unmarked one as an injection. A resumed session shows the message without it. */
/** What /continue (ctrl+g) sends: carry on after being stopped, or after an answer that ended
* too soon. */
export const CONTINUE_PROMPT = continueText.replace(/<!--[\s\S]*?-->\n?/g, "").trim()
export const STEER_MARK = "[The user sent this while you were working — take it into account from here:]"
export const JOB_MARK = "[A background job you started has finished:]"
/** Neighbouring user messages as one: a message sent mid-turn after the image message, or after a
* nudge, would otherwise be two user turns in a row, which strict chat templates (llama.cpp's
* Jinja ones) refuse. Anthropic and Gemini merge them anyway. */
export function mergeUsers(messages: Message[]): Message[] {
const out: Message[] = []
for (const m of messages) {
const last = out[out.length - 1]
if (m.role === "user" && last?.role === "user") out[out.length - 1] = { role: "user", parts: [...last.parts, ...m.parts] }
else out.push(m)
}
return out
}
const NO_ANSWER =
"Your last reply contained only thinking and no answer, so the user saw nothing. Write your reply to the user now."
const CUT_THINKING =
"Your last reply hit the output limit while you were still thinking, so nothing was said or done. Think less this time: act now with a tool call, or answer."
const CUT_TEXT = "Your last reply was cut off at the output limit. Carry on from exactly where it stopped."
/** Replies in a row that say nothing (only thinking, or cut off) get asked again this many times. */
const MAX_NUDGES = 2
const wrapUpText = (why: string) =>
`You have reached the ${why} for this reply. Do not call any more tools. Answer now from what you have: say what is done, what is not, and what you would do next.`
export class Engine {
messages: Message[] = []
mode: Mode
private _effort: Effort | null
usage: Usage | undefined
/** messages.length when `usage` was reported: everything after it is not yet counted. */
usageAt = 0
/** The user message that started the running turn, and automatic compactions within it. */
private turnPrompt: Message | undefined
private autoCompactions = 0
model: ResolvedModel
client: Client
sessionId: string | undefined
busy = false
/** Store row id of the last message pushed (for /undo ranges). */
lastRowId = 0
/** Output of `!` commands, sent along with the next prompt. */
pendingContext: string[] = []
/** Messages the user sent while a task runs, not yet given to the model. */
private inbox: string[] = []
/** Beside the inbox, entry for entry: the id the sender gave a message (the web UI's message id), so where it was taken in can be shown at that point of the reply. */
private inboxIds: (string | undefined)[] = []
/** What happened while the task ran that is not the user's word: background jobs that finished.
* Given to the model at the next step, like a steer; left over at the end, `takeNotes` hands
* them out to start a turn of their own. */
private notes: string[] = []
/** Those given to the model in this turn: kept word for word through a compaction, like the prompt. */
private turnSteers: string[] = []
/** busy_input: steer — the inbox goes in at the next step; queue — it waits for the task to end. */
busyInput: "steer" | "queue" = "steer"
/** The todo tool's list, for this session. */
todos: { items: Todo[]; changed?: (items: Todo[]) => void } = { items: [] }
private sessionRules: Rule[] = []
private recentCalls: string[] = []
/** Tools refused with no one able to approve them: no longer offered. */
private withdrawn = new Set<string>()
/** This prompt's spending, against `budget`: time waiting for the user, tool output, tokens written. */
private turnWaitedMs = 0
private turnOutputBytes = 0
private turnCompletion = 0
private abort: AbortController | undefined
constructor(readonly o: EngineOptions) {
this.mode = o.mode
this._effort = o.effort
this.model = o.model
this.client = o.client
this.sessionId = o.sessionId
this.todos.changed = (items) => o.bus.emit({ type: "todos", items })
}
get effort(): Effort | null {
return this._effort
}
/** Said on the bus when it changes: the web UI's chat shows the device's model and effort,
* whoever changed them. */
set effort(v: Effort | null) {
if (v === this._effort) return
this._effort = v
this.o.bus.emit({ type: "model", ref: this.model.ref, effort: v ?? "off", connection: this.model.connectionName })
}
/** Switch model mid-session. The conversation carries over; the effort resets to the new model's. */
setModel(model: ResolvedModel, client: Client, effort: Effort | null) {
const changed = model.ref !== this.model.ref || effort !== this._effort
this.model = model
this.client = client
this._effort = effort
if (changed) this.o.bus.emit({ type: "model", ref: model.ref, effort: effort ?? "off", connection: model.connectionName })
}
/** Start over: a fresh conversation (and session row). Approvals given "for the session" end. */
newSession(sessionId: string | undefined) {
this.messages = []
this.usage = undefined
this.sessionId = sessionId
this.sessionRules = []
this.recentCalls = []
this.withdrawn.clear()
this.todos.items = []
this.o.bus.emit({ type: "todos", items: [] })
this.o.toolCtx.readFiles.clear()
this.o.toolCtx.fileStamps.clear()
}
/** Continue a stored session. Files must be read again before they are changed. */
resume(sessionId: string, messages: Message[]) {
this.newSession(sessionId)
this.messages = messages
}
/** Replace the conversation with a summary of it (/compact). */
async compact(): Promise<string> {
if (this.busy) throw new Error("busy")
if (this.messages.length < 2) throw new Error("nothing to compact yet")
this.busy = true
this.abort = new AbortController()
try {
return await this.summarise(this.abort.signal)
} finally {
this.busy = false
}
}
private async summarise(signal: AbortSignal): Promise<string> {
const previous = this.messages[0]?.role === "user" && this.messages[0].parts[0]?.type === "text" && this.messages[0].parts[0].text.startsWith("The earlier part of this conversation was compacted")
? this.messages[0].parts[0].text
: ""
const prompt = fill(promptText("tasks/compact.md"), {
previous_summary: previous ? `## Summary of even earlier turns\n\n${previous}` : "",
transcript: transcript(this.messages, 60_000),
})
let summary = ""
for await (const ev of this.client.stream({ system: "", messages: [{ role: "user", parts: [{ type: "text", text: prompt }] }], tools: [], effort: null, signal }))
if (ev.type === "finish") summary = ev.message.parts.filter((p) => p.type === "text").map((p) => (p as { text: string }).text).join("").trim()
if (!summary) throw new Error("the model returned an empty summary")
if (this.o.store && this.sessionId) this.o.store.compaction(this.sessionId, summary)
this.messages = summaryTurns(summary)
this.usage = undefined
this.usageAt = 0
// Said on the bus: the web UI marks the point in a shared session, as the TUI does.
this.o.bus.emit({ type: "compacted", summary })
return summary
}
/** Tokens in use now: the last reported usage plus an estimate of what was added since. The
* reply's thinking is left out where the dialect never sends it back (openai-chat): it is in the
* last reply's output but will not be in the next request. */
contextUsed(): number | undefined {
if (!this.usage) return undefined
return this.usage.input + this.usage.output - this.droppedThinking() + estimateTokens(JSON.stringify(this.messages.slice(this.usageAt)))
}
private droppedThinking(): number {
if (!this.usage || this.model.connection.dialect !== "openai-chat") return 0
const reply = this.messages[this.usageAt - 1]
if (reply?.role !== "assistant") return 0
const estimate = reply.parts.reduce((n, p) => n + (p.type === "reasoning" ? estimateTokens(p.text) : 0), 0)
return Math.min(this.usage.output, this.usage.reasoning ?? estimate)
}
/** Replace the content of old tool results, keeping the most recent `protect` tokens whole.
* Returns the tokens saved. (OpenCode prunes the same way before it summarises.) */
prune(protect: number): number {
let recent = 0
let saved = 0
for (let i = this.messages.length - 1; i >= 0; i--) {
const m = this.messages[i]!
const size = estimateTokens(JSON.stringify(m))
if (recent < protect) {
recent += size
continue
}
if (m.role === "tool" && m.content.length > 400 && !m.content.startsWith(PRUNED)) {
saved += estimateTokens(m.content) - estimateTokens(PRUNED)
this.messages[i] = { ...m, content: PRUNED }
}
}
return saved
}
/** Before a step: past compaction.auto_at of the window, prune; if that is not enough, compact. */
private async manageContext(signal: AbortSignal) {
const window = contextWindow(this.model)
const autoAt = this.o.compaction?.autoAt ?? 0.85
if (!window || autoAt >= 1) return
const limit = window * autoAt
let used = this.contextUsed()
if (used === undefined || used < limit) return
if (this.o.compaction?.prune !== false) {
const saved = this.prune(Math.min(40_000, Math.floor(window * 0.3)))
if (saved > 0) {
used -= saved
this.o.bus.emit({ type: "notice", message: `context at ${Math.round(((used + saved) / window) * 100)}%: pruned old tool outputs, ~${saved} tokens freed` })
}
if (used < limit) return
}
// Compacting again and again within one turn loses more each time: the window is simply too
// small for the task. Say so once and stop, rather than summarising in circles.
if (this.autoCompactions >= 2) {
if (this.autoCompactions === 2) {
this.autoCompactions++
this.o.bus.emit({ type: "notice", message: `the ${window}-token window is too small for this task even after compacting twice; carrying on without compacting again — a model with a larger window would do better` })
}
return
}
this.autoCompactions++
this.o.bus.emit({ type: "notice", message: `context at ${Math.round((used / window) * 100)}% of ${window}: compacting the conversation` })
try {
await this.summarise(signal)
// The task is never left to the summary: the prompt that started this turn comes back word
// for word (found on a real run, where a small model's summary lost the task entirely).
const original = this.turnPrompt?.role === "user" ? this.turnPrompt.parts : []
const steers = this.turnSteers.map((s) => ({ type: "text" as const, text: `(Sent while you were working:) ${s}` }))
this.push({
role: "user",
parts: [
...original,
...steers,
{ type: "text", text: "(The conversation was compacted to fit the context window; the summary above records what was done so far. This was the request — continue it from where the summary leaves off.)" },
],
})
this.o.bus.emit({ type: "notice", message: "compacted — carrying on from the summary, with your request kept word for word" })
} catch (e) {
this.o.bus.emit({ type: "notice", message: `automatic compaction failed: ${(e as Error).message}` })
}
}
/** Time spent waiting for the user does not count against the wall-clock budget: somebody who
* thinks for ten minutes about one command has not spent the agent's time. */
private async waiting<T>(p: Promise<T>): Promise<T> {
const t0 = performance.now()
try {
return await p
} finally {
this.turnWaitedMs += performance.now() - t0
}
}
/** Which budget this prompt has run past, said for the user, or "" while there is room. */
private overBudget(started: number): string {
const b = this.o.budget
if (!b) return ""
const spent = (performance.now() - started - this.turnWaitedMs) / 1000
if (b.wall_seconds && spent > b.wall_seconds) return `after ${duration(spent * 1000, true)}`
if (b.output_bytes && this.turnOutputBytes > b.output_bytes) return "with too much tool output to read"
if (b.completion_tokens && this.turnCompletion > b.completion_tokens) return `after writing about ${this.turnCompletion.toLocaleString("en")} tokens`
return ""
}
/** Tools a model can use at all: view_image only with vision. */
private offered(name: string): boolean {
const t = this.o.tools.find((x) => x.name === name)
if (name === "task" && !this.o.spawn) return false
if (!t?.requires) return true
if (t.requires === "vision") return this.model.spec.vision === true
if (t.requires === "project") return this.o.toolCtx.projectDir !== undefined
if (t.requires === "knowledge") {
const L = this.o.toolCtx.library
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
return have.some((b) => !L!.bases || L!.bases.includes(b))
}
return this.mode === "plan"
}
cancel() {
this.abort?.abort()
}
/** A message the user sent while a task runs. It goes to the model at the next step (steer), or
* is held for the task's end (queue, or a turn that ends first): `takeInbox` gives it back. */
enqueue(text: string, id?: string) {
this.inbox.push(text)
this.inboxIds.push(id)
this.o.bus.emit({ type: "inbox", texts: [...this.inbox], mode: this.busyInput })
}
/** How many messages wait. */
get queued(): number {
return this.inbox.length
}
/** Take one waiting message out (↑ in the box, to look at or edit it); `putBack` returns it. */
unqueue(index: number): string | undefined {
if (index < 0 || index >= this.inbox.length) return undefined
const [text] = this.inbox.splice(index, 1)
this.inboxIds.splice(index, 1)
this.o.bus.emit({ type: "inbox", texts: [...this.inbox], mode: this.busyInput })
return text
}
putBack(index: number, text: string) {
const at = Math.max(0, Math.min(index, this.inbox.length))
this.inbox.splice(at, 0, text)
this.inboxIds.splice(at, 0, undefined)
this.o.bus.emit({ type: "inbox", texts: [...this.inbox], mode: this.busyInput })
}
/** What is still waiting, emptied: the task ended (or was stopped) before it was delivered. */
takeInbox(): string[] {
const texts = this.inbox.splice(0)
this.inboxIds.splice(0)
if (texts.length) this.o.bus.emit({ type: "inbox", texts: [], mode: this.busyInput })
return texts
}
/** A background job ended while a task runs: it goes to the model at the next step. */
note(text: string) {
this.notes.push(text)
}
/** Notes the task ended before delivering. */
takeNotes(): string[] {
return this.notes.splice(0)
}
/** At a step boundary — every tool result of the last step is in — hand the inbox to the model. */
private deliverInbox(): boolean {
const notes = this.notes.splice(0)
if (notes.length) {
this.push({ role: "user", parts: [{ type: "text", text: `${JOB_MARK}\n${notes.join("\n\n")}` }] })
this.o.bus.emit({ type: "notice", message: notes.map((n) => n.split("\n")[0]!).join("; ") })
}
if (this.busyInput !== "steer" || !this.inbox.length) return notes.length > 0
const texts = this.inbox.splice(0)
const ids = this.inboxIds.splice(0).filter((i): i is string => typeof i === "string")
this.turnSteers.push(...texts)
this.push({ role: "user", parts: [{ type: "text", text: `${STEER_MARK}\n${texts.join("\n\n")}` }] })
this.o.bus.emit({ type: "steered", texts, ...(ids.length ? { ids } : {}) })
this.o.bus.emit({ type: "inbox", texts: [], mode: this.busyInput })
return true
}
private push(m: Message) {
this.messages.push(m)
if (this.o.store && this.sessionId) this.lastRowId = this.o.store.append(this.sessionId, m)
}
/** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed.
* `meta`: the turn's id and what was attached, said on the `prompt` event. */
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
if (this.busy) throw new Error("busy")
this.busy = true
this.o.bus.emit({ type: "prompt", text: shown, ...(meta.turnId ? { turnId: meta.turnId } : {}), ...(meta.attachments?.length ? { attachments: meta.attachments } : {}) })
try {
return await this.run(text, [...this.pendingContext.splice(0), ...extra], meta.turnId)
} finally {
this.busy = false
}
}
private async run(text: string, extra: (string | ImagePart)[], turnId?: string): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
const { bus } = this.o
this.abort = new AbortController()
const signal = this.abort.signal
this.push({ role: "user", parts: [{ type: "text", text }, ...extra.map((t) => (typeof t === "string" ? { type: "text" as const, text: t } : t))], ...(turnId ? { turnId } : {}) })
this.turnPrompt = this.messages[this.messages.length - 1]
this.turnSteers = []
this.autoCompactions = 0
const toolMap = new Map(this.o.tools.map((t) => [t.name, t]))
const allSpecs = this.o.tools.map(toSpec)
const maxSteps = this.o.maxSteps ?? 200
const started = performance.now()
this.turnWaitedMs = 0
this.turnOutputBytes = 0
this.turnCompletion = 0
// Why the prompt is being wound up early, once a budget has run out: then, as past the step
// limit, the tools are withdrawn and the model answers from what it has.
let ranOut = ""
let nudges = 0
let midRetries = 0
const tried = new Set<string>()
for (let step = 1; ; step++) {
// Checked between steps, never mid-reply: a reply cut off mid-sentence is worth nothing.
if (!ranOut && step > 1) {
ranOut = this.overBudget(started)
if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut} — the model answers from what it has; send a message to carry on` })
}
const wrapUp = step > maxSteps || ranOut !== ""
if (!wrapUp) await this.manageContext(signal)
// After compacting, so what was just sent is never folded into a summary.
if (!wrapUp) this.deliverInbox()
bus.emit({ type: "step", n: step })
let assistant: Extract<Message, { role: "assistant" }> | undefined
let finish = "stop"
let usage: Usage | undefined
// Output speed: from the first piece of the reply to the last.
let firstOut = 0
let lastOut = 0
// What was said before a stop: kept, so /continue picks up from there.
let partial = ""
try {
const stream = this.client.stream({
system: this.o.system(this.mode, this.model),
messages: mergeUsers(wrapUp ? [...this.messages, { role: "user", parts: [{ type: "text", text: wrapUpText(ranOut ? "budget" : "step limit") }] }] : this.messages),
tools: wrapUp ? [] : allSpecs.filter((s) => !this.withdrawn.has(s.name) && this.offered(s.name)),
effort: this.effort,
signal,
})
for await (const ev of stream) {
if (ev.type === "finish") {
assistant = ev.message
finish = ev.reason
} else if (ev.type === "usage") usage = ev.usage
else {
if (ev.type === "text" || ev.type === "reasoning" || ev.type === "tool_call_delta") {
lastOut = performance.now()
firstOut ||= lastOut
}
if (ev.type === "text") partial += ev.text
bus.emit(ev)
}
}
} catch (e) {
if (signal.aborted) {
// The reply stays on screen; it stays in the history too, so the model knows what it
// had already said when it is asked to continue.
if (partial.trim()) this.push({ role: "assistant", parts: [{ type: "text", text: partial }] })
bus.emit({ type: "done", reason: "cancelled" })
return "cancelled"
}
// The server is not there at all: the model's fallback, if it has one, for the rest of the
// session — said, and shown in the status bar.
if (e instanceof ProviderError && e.unreachable && this.o.fallback) {
tried.add(this.model.ref)
const next = this.o.fallback(this.model, tried)
if (next) {
bus.emit({ type: "notice", message: `${e.message.split("\n")[0]!.slice(0, 160)} — switching to ${next.model.ref}, its fallback` })
this.setModel(next.model, next.client, next.effort)
bus.emit({ type: "setting", key: "model", value: next.model.ref })
step--
continue
}
}
// A server failure part-way through a reply (llama.cpp failing to parse gpt-oss's own
// output is the one seen) is worth two more goes. What was already shown is discarded.
if (e instanceof ProviderError && !e.beforeOutput && (e.status ?? 0) >= 500 && midRetries < 2) {
midRetries++
bus.emit({ type: "retract" })
bus.emit({ type: "notice", message: `the server failed mid-reply (${e.message.split("\n")[0]!.slice(0, 120)}); the partial reply is discarded and the step retried` })
step--
continue
}
bus.emit({ type: "error", message: (e as Error).message })
bus.emit({ type: "done", reason: "error" })
return "error"
}
if (!assistant) {
bus.emit({ type: "error", message: "the stream ended without a reply" })
bus.emit({ type: "done", reason: "error" })
return "error"
}
this.push(assistant)
if (usage) {
this.turnCompletion += usage.output
// Counted once the reply is in the history, so the reply itself is not estimated again.
this.usage = usage
this.usageAt = this.messages.length
const secs = (lastOut - firstOut) / 1000
const rate = usage.output > 0 && secs > 0.3 ? usage.output / secs : undefined
bus.emit({ type: "usage", usage, context: contextWindow(this.model), used: this.contextUsed(), rate })
}
const calls = assistant.parts.filter((p): p is ToolCallPart => p.type === "tool_call")
const said = assistant.parts.some((p) => p.type === "text" && p.text.trim())
const cut = finish === "length"
const limit = this.model.spec.max_output ? ` (${this.model.spec.max_output.toLocaleString("en")} tokens)` : ""
if (calls.length) nudges = 0
// A thinking model can finish its whole answer inside the reasoning channel, or spend the
// whole output limit thinking. Ask again — a few times in a row, then stop and say so.
if (calls.length === 0 && (!said || cut) && !wrapUp) {
if (nudges < MAX_NUDGES) {
nudges++
bus.emit({
type: "notice",
message: !cut
? "the reply had no answer outside its thinking; asking for one"
: said
? `the reply was cut off at the output limit${limit}; asking it to carry on`
: `the model used the whole output limit${limit} thinking; asking it to act`,
})
this.push({ role: "user", parts: [{ type: "text", text: !cut ? NO_ANSWER : said ? CUT_TEXT : CUT_THINKING }] })
continue
}
bus.emit({
type: "notice",
level: "warn",
message: cut
? `stopped: ${MAX_NUDGES + 1} replies in a row ran into the output limit${limit}. Raise max_output for this model, lower the effort, or say what to do next`
: `stopped: ${MAX_NUDGES + 1} replies in a row had only thinking in them. Say what to do next`,
})
}
if (cut && calls.length)
bus.emit({ type: "notice", level: "warn", message: `the reply hit the output limit${limit} while writing a tool call; a call cut short is not run, and the model is told to split it` })
// The model is done, but the user said something meanwhile: it carries on with that, in the
// same task.
if (calls.length === 0 && !wrapUp && (this.notes.length || (this.busyInput === "steer" && this.inbox.length))) continue
if (calls.length === 0 || wrapUp) {
const reason = wrapUp ? (ranOut ? "budget" : "steps") : "stop"
bus.emit({ type: "done", reason })
return reason
}
// Decide every call first — asking is sequential — then run what was allowed.
const results = new Map<string, ToolResult>()
const runnable: { call: ToolCallPart; tool: Tool; args: unknown; paths: string[]; writes: boolean; edited?: string }[] = []
for (const call of calls) {
if (signal.aborted) break
const decided = await this.decide(call, toolMap, cut)
if ("result" in decided) {
results.set(call.id, decided.result)
if (decided.denied) bus.emit({ type: "tool_denied", id: call.id, name: call.name, reason: decided.result.output })
} else runnable.push({ call, ...decided })
}
const toolCtx: ToolContext = {
...this.o.toolCtx,
signal,
question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined,
plan: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined,
setMode: (m) => {
this.mode = m
bus.emit({ type: "mode", mode: m })
},
todos: this.todos,
spawn: this.o.spawn,
}
// Consecutive ordinary calls run together (up to MAX_PARALLEL); an exclusive one — it waits
// on the user — runs on its own, in its place in the order.
const groups: (typeof runnable)[] = []
// Two calls on the same file never run together: each reads, changes and writes it whole,
// so the second would write over the first.
for (const r of runnable) {
const last = groups[groups.length - 1]
const clash = last?.some((o) => o.paths.some((p) => r.paths.includes(p)) && (o.writes || r.writes))
if (r.tool.exclusive || !last || last[0]!.tool.exclusive || last.length >= MAX_PARALLEL || clash) groups.push([r])
else last.push(r)
}
for (const group of groups) {
await Promise.all(
group.map(async ({ call, tool, args, edited }) => {
bus.emit({ type: "tool_start", id: call.id, name: call.name, args })
const t0 = performance.now()
let result: ToolResult
try {
result = await tool.run(args, { ...toolCtx, callId: call.id })
} catch (e) {
result = { output: e instanceof ToolError ? e.message : `${tool.name} failed: ${(e as Error).message}`, isError: true }
}
// Said first, so the model knows the line that ran is not the one it wrote.
if (edited) result = { ...result, output: `The user changed the command before allowing it. What ran: ${edited}\n\n${result.output}` }
results.set(call.id, result)
bus.emit({ type: "tool_end", id: call.id, name: call.name, result, ms: performance.now() - t0 })
}),
)
}
const images: ImagePart[] = []
for (const call of calls) {
const r = results.get(call.id) ?? { output: "Not run: the reply was cancelled.", isError: true }
this.turnOutputBytes += Buffer.byteLength(r.output)
this.push({ role: "tool", callId: call.id, name: call.name, content: r.output, isError: r.isError })
if (r.images) images.push(...r.images)
}
// No dialect takes an image inside a tool result the same way; a user turn right after the
// results works everywhere.
if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images] })
if (signal.aborted) {
bus.emit({ type: "done", reason: "cancelled" })
return "cancelled"
}
}
}
private async decide(
call: ToolCallPart,
toolMap: Map<string, Tool>,
cut = false,
): Promise<{ tool: Tool; args: unknown; paths: string[]; writes: boolean; edited?: string } | { result: ToolResult; denied?: boolean }> {
// A former name (harness spec v1 renamed a few) runs the tool it now is.
let tool = toolMap.get(call.name) ?? toolMap.get(resolveCall(call.name, {}).name)
if (!tool)
return { result: { output: `There is no tool named "${call.name}". The tools are: ${[...toolMap.keys()].join(", ")}.`, isError: true } }
let raw: unknown
try {
raw = call.args.trim() ? JSON.parse(call.args) : {}
} catch {
if (cut)
return {
result: {
output:
`Not run: your reply hit the output limit${this.model.spec.max_output ? ` of ${this.model.spec.max_output} tokens` : ""} before these arguments were complete. ` +
"Do not send the same call again whole. Split large content over several calls — create the file with the first part, then add the rest with edit — and think less before acting.",
isError: true,
},
}
this.o.bus.emit({ type: "notice", message: `a ${call.name} call's arguments were not valid JSON; it was not run and the model was told` })
return { result: { output: `The arguments were not valid JSON: ${call.args.slice(0, 200)}`, isError: true } }
}
const resolved = resolveCall(call.name, raw)
tool = toolMap.get(resolved.name) ?? tool
raw = resolved.raw
// What the call is for goes to the user when they are asked; the tool never sees an added `purpose`.
const { args: own, purpose } = splitPurpose(tool, raw)
const parsed = tool.schema.safeParse(own)
if (!parsed.success) {
const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(arguments)"}: ${i.message}`).join("; ")
return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } }
}
let args = parsed.data
const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal }
let request = tool.permission(args, toolCtx)
let edited: string | undefined
const ctx: PermContext = { ...this.o.perm, mode: this.mode, rules: [...this.o.perm.rules, ...this.sessionRules] }
let decision = evaluate(request, ctx)
// Doom-loop guard: the same call three times running is asked about, whatever the mode.
const key = `${call.name} ${JSON.stringify(args)}`
this.recentCalls = [...this.recentCalls.slice(-2), key]
const looping = this.recentCalls.length === 3 && this.recentCalls.every((k) => k === key)
if (decision.action === "allow" && looping) decision = { ...decision, action: "ask", reason: "the same call three times in a row" }
if (decision.action === "deny") return { result: { output: `Denied: ${decision.reason}`, isError: true }, denied: true }
if (decision.action === "ask") {
// The change itself, so the user sees what they are allowing — not just the file's name.
let preview: { diff?: string; error?: string } | undefined
if (tool.preview)
preview = await tool.preview(args, toolCtx).then(
(diff) => ({ diff }),
(e) => ({ error: (e as Error).message }),
)
const reply = await this.waiting(this.o.asker.ask({ tool: tool.name, args, request, decision, preview, purpose }))
if (reply.kind === "deny" && looping)
return {
result: {
output: `Not run: this is the same ${tool.name} call three times in a row, and its result will not change. Use what you already have, or do something different.`,
isError: true,
},
denied: true,
}
// Withdrawing a tool is for "this kind of action cannot happen here" — a change or a command.
// A refused read (a .env, a path outside the project) refuses that call, not reading.
const withdraw = reply.kind === "deny" && reply.final && (request.class === "write" || request.class === "execute") && !decision.reason.startsWith("outside")
if (reply.kind === "deny" && withdraw) {
// Every tool behind the same permission goes: edit and write share "edit".
const siblings = this.o.tools.filter((t) => t.access === tool.access)
for (const t of siblings) this.withdrawn.add(t.name)
const gone = siblings.map((t) => t.name).join(", ")
return {
result: {
output: `Refused: ${reply.feedback ?? "nobody can approve this"} It will be refused every time in this session, so ${gone} ${gone.includes(",") ? "are" : "is"} no longer offered. Do not look for a way around it. Finish with what you can do without it, and say exactly what you would have done — the change, the command — so the user can do it.`,
isError: true,
},
denied: true,
}
}
if (reply.kind === "deny") {
const output = reply.feedback
? `The user declined this, and said why: ${reply.feedback}\nTake that as their instruction and carry on from it.`
: "The user declined this call. Do not retry it unchanged; adjust, or ask what they want."
return { result: { output, isError: true }, denied: true }
}
// The user corrected the command before allowing it. What they wrote is judged again — the
// hardline and a written deny hold for it too — and runs as allowed; the model is told.
if (reply.kind === "once" && reply.command !== undefined && request.command !== undefined && reply.command.trim() && reply.command.trim() !== request.command) {
const line = reply.command.trim()
const changed = tool.schema.safeParse({ ...(args as Record<string, unknown>), command: line })
const again = changed.success ? tool.permission(changed.data, toolCtx) : undefined
const verdict = again ? evaluate(again, ctx) : undefined
if (!changed.success || !again || verdict!.action === "deny")
return { result: { output: `The user changed the command to \`${line}\`, and that is refused: ${verdict?.reason ?? "it is not a valid command"}. Nothing ran.`, isError: true }, denied: true }
args = changed.data
request = again
edited = line
}
if (reply.kind === "session" || reply.kind === "project") {
for (const pattern of decision.always) {
// Learned: an approval never overrides a deny somebody wrote (see evaluate).
const rule: Rule = { permission: request.permission, pattern, action: "allow", learned: true }
this.sessionRules.push(rule)
// Written to the project, it would come back as an ordinary rule and win over such a
// deny by coming last; it stays for this session only instead.
if (reply.kind === "project" && !shadowsDeny(rule, this.o.perm.rules)) this.o.persistRule?.({ permission: rule.permission, pattern, action: "allow" })
}
}
}
// A command's path is its working directory, not a file it changes.
return { tool, args, paths: request.command === undefined ? (request.paths ?? []) : [], writes: request.class !== "read", edited }
}
}
/** The conversation as plain text for the summariser, newest kept when it is too long. */
export function transcript(messages: Message[], maxChars: number): string {
const lines: string[] = []
for (const m of messages) {
if (m.role === "tool") lines.push(`[${m.name} result${m.isError ? ", error" : ""}]\n${m.content.slice(0, 2000)}`)
else
for (const p of m.parts) {
if (p.type === "text") lines.push(`${m.role === "user" ? "User" : "Assistant"}: ${p.text}`)
else if (p.type === "tool_call") lines.push(`[assistant called ${p.name} ${p.args.slice(0, 400)}]`)
}
}
const all = lines.join("\n\n")
return all.length <= maxChars ? all : "[…earlier turns cut…]\n\n" + all.slice(-maxChars)
}
+224
View File
@@ -0,0 +1,224 @@
import { Database } from "bun:sqlite"
import { mkdirSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
import type { Message } from "../provider/types.ts"
// Sessions and their messages, in ~/.local/share/lembas/sessions.db. Message text is also
// indexed with FTS5 so past sessions can be searched (session_search).
export interface SessionRow {
id: string
created: number
updated: number
title: string
root: string
model: string
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, created INTEGER NOT NULL, updated INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT '', root TEXT NOT NULL, model TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL, json TEXT NOT NULL, created INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'message'
);
CREATE INDEX IF NOT EXISTS messages_session_kind ON messages(session_id, kind, id);
CREATE INDEX IF NOT EXISTS messages_session ON messages(session_id, id);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(text, content='', contentless_delete=1);
CREATE TABLE IF NOT EXISTS session_meta (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (session_id, key)
);
`
/** The session_meta key saying a session has been in the web UI's hands (its rules are in
* acp/agent.ts, which re-exports it). Here, so the app can ask it too without importing the
* agent, which imports the app. */
export const REMOTE_META = "remote"
/** The session_meta key holding the directory a session was started in: a session started
* in a subdirectory of its project is reopened there, not at the project's root. */
export const CWD_META = "cwd"
export function plainText(m: Message): string {
if (m.role === "tool") return m.content
return m.parts.map((p) => (p.type === "text" || p.type === "reasoning" ? p.text : p.type === "tool_call" ? `${p.name} ${p.args}` : "")).join("\n")
}
export interface Row {
id: number
role: string
json: string
created: number
}
export interface Hit extends Row {
session_id: string
}
export class Store {
readonly db: Database
constructor(file = join(paths.data, "sessions.db")) {
if (file !== ":memory:") mkdirSync(paths.data, { recursive: true })
this.db = new Database(file, { create: true })
this.db.run("PRAGMA journal_mode = WAL")
// Several writers share this file: every terminal, the service, and in the service one
// connection per session it holds. Without a timeout a write that meets another's lock fails at
// once with "database is locked" — a delete from the web UI among them, which the instance then
// only saw as a refusal. Writes here are short; waiting for one is always right.
this.db.run("PRAGMA busy_timeout = 5000")
this.db.run("PRAGMA foreign_keys = ON")
// Additive migrations: a column is added when missing, never renamed or dropped.
this.db.exec(SCHEMA.split("CREATE INDEX IF NOT EXISTS messages_session_kind")[0]!)
const cols = (this.db.query("PRAGMA table_info(messages)").all() as { name: string }[]).map((c) => c.name)
if (!cols.includes("kind")) this.db.run("ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'message'")
this.db.exec(SCHEMA)
}
createSession(root: string, model: string): SessionRow {
const now = Date.now()
const id = `ses_${now.toString(36)}${Math.random().toString(36).slice(2, 8)}`
this.db.query("INSERT INTO sessions (id, created, updated, root, model) VALUES (?, ?, ?, ?, ?)").run(id, now, now, root, model)
return { id, created: now, updated: now, title: "", root, model }
}
append(sessionId: string, m: Message): number {
const now = Date.now()
const r = this.db.query("INSERT INTO messages (session_id, role, json, created) VALUES (?, ?, ?, ?)").run(sessionId, m.role, JSON.stringify(m), now)
const text = plainText(m)
if (text.trim()) this.db.query("INSERT INTO messages_fts (rowid, text) VALUES (?, ?)").run(r.lastInsertRowid, text)
this.db.query("UPDATE sessions SET updated = ? WHERE id = ?").run(now, sessionId)
return Number(r.lastInsertRowid)
}
/** Delete a session and everything in it: /sessions delete, /delete, or the web UI
* deleting the chat it is. The search index has no content of its own, so its rows go by id. */
deleteSession(sessionId: string): boolean {
const del = this.db.transaction(() => {
this.db.query("DELETE FROM messages_fts WHERE rowid IN (SELECT id FROM messages WHERE session_id = ?)").run(sessionId)
return this.db.query("DELETE FROM sessions WHERE id = ?").run(sessionId).changes > 0
})
// IMMEDIATE: the write lock taken up front, so busy_timeout applies. A deferred
// transaction starts by reading (the subquery), and one that then meets another process's
// write lock is refused at once — SQLite will not wait in a way that could deadlock — so a
// delete from the web UI that met a terminal writing a turn failed as "database is locked".
return del.immediate()
}
/** /undo and /redo: hide or bring back the rows of a turn. They are never deleted. */
setKind(sessionId: string, fromId: number, toId: number, kind: "message" | "reverted") {
const other = kind === "message" ? "reverted" : "message"
this.db.query("UPDATE messages SET kind = ? WHERE session_id = ? AND id BETWEEN ? AND ? AND kind = ?").run(kind, sessionId, fromId, toId, other)
}
/** Record a compaction: what the model sees from here on starts with this summary. */
compaction(sessionId: string, summary: string) {
this.db
.query("INSERT INTO messages (session_id, role, json, created, kind) VALUES (?, 'user', ?, ?, 'compaction')")
.run(sessionId, JSON.stringify({ summary }), Date.now())
}
/** The conversation as the model sees it: from the last compaction on, the summary first. */
context(sessionId: string): Message[] {
const last = this.db
.query("SELECT id, json FROM messages WHERE session_id = ? AND kind = 'compaction' ORDER BY id DESC LIMIT 1")
.get(sessionId) as { id: number; json: string } | null
const after = last?.id ?? 0
const rows = this.db
.query("SELECT json FROM messages WHERE session_id = ? AND kind = 'message' AND id > ? ORDER BY id")
.all(sessionId, after) as { json: string }[]
const msgs = rows.map((r) => JSON.parse(r.json) as Message)
return last ? [...summaryTurns(JSON.parse(last.json).summary as string), ...msgs] : msgs
}
/** Something a session keeps besides its messages, as JSON. */
meta<T>(sessionId: string, key: string): T | undefined {
const r = this.db.query("SELECT value FROM session_meta WHERE session_id = ? AND key = ?").get(sessionId, key) as { value: string } | null
return r ? (JSON.parse(r.value) as T) : undefined
}
setMeta(sessionId: string, key: string, value: unknown) {
this.db.query("INSERT INTO session_meta (session_id, key, value) VALUES (?, ?, ?) ON CONFLICT (session_id, key) DO UPDATE SET value = excluded.value").run(sessionId, key, JSON.stringify(value))
}
setTitle(sessionId: string, title: string) {
this.db.query("UPDATE sessions SET title = ? WHERE id = ?").run(title, sessionId)
}
/** The text of a session's first user message (sessions from before titles have only this). */
firstPrompt(sessionId: string): string {
const r = this.db.query("SELECT json FROM messages WHERE session_id = ? AND role = 'user' AND kind = 'message' ORDER BY id LIMIT 1").get(sessionId) as { json: string } | null
if (!r) return ""
const m = JSON.parse(r.json) as Message
return m.role === "user" ? m.parts.map((p) => (p.type === "text" ? p.text : "")).find((t) => t.trim()) ?? "" : ""
}
messages(sessionId: string): Message[] {
return (this.db.query("SELECT json FROM messages WHERE session_id = ? AND kind = 'message' ORDER BY id").all(sessionId) as { json: string }[]).map(
(r) => JSON.parse(r.json) as Message,
)
}
/** Newest first. `withMessages`: only sessions somebody said something in (every start and /new
* makes one, so the empty ones would crowd out the rest). */
sessions(limit = 50, root?: string, withMessages = false): SessionRow[] {
const where = [root ? "s.root = ?" : "", withMessages ? "EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id AND m.kind = 'message')" : ""].filter(Boolean)
return this.db
.query(`SELECT s.* FROM sessions s ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY s.updated DESC LIMIT ?`)
.all(...(root ? [root] : []), limit) as SessionRow[]
}
/** FTS hits in visible user/assistant messages (not reverted, not tool output), best first.
* `root` keeps one project's sessions; `perSession` the best hit of each session. Both are done
* before the limit, so a busy other project cannot push this one's hits out. */
hits(match: string, o: { exclude?: string[]; sort?: "newest" | "oldest"; limit?: number; roles?: string[]; root?: string; perSession?: boolean } = {}): Hit[] {
const roles = o.roles ?? ["user", "assistant"]
const ex = o.exclude ?? []
const order = (t: string) => (o.sort === "newest" ? `${t}.created DESC, ${t}.id DESC` : o.sort === "oldest" ? `${t}.created ASC, ${t}.id ASC` : `${t}.r, ${t}.id`)
const inner = `SELECT m.session_id, m.id, m.role, m.json, m.created, f.rank AS r FROM messages_fts f JOIN messages m ON m.id = f.rowid
${o.root !== undefined ? "JOIN sessions s ON s.id = m.session_id AND s.root = ?" : ""}
WHERE messages_fts MATCH ? AND m.kind = 'message' AND m.role IN (${roles.map(() => "?").join(",")})
${ex.length ? `AND m.session_id NOT IN (${ex.map(() => "?").join(",")})` : ""}`
const sql = o.perSession
? `SELECT session_id, id, role, json, created FROM (SELECT h.*, row_number() OVER (PARTITION BY h.session_id ORDER BY ${order("h")}) AS n FROM (${inner}) h) x WHERE x.n = 1 ORDER BY ${order("x")} LIMIT ?`
: `SELECT session_id, id, role, json, created FROM (${inner}) h ORDER BY ${order("h")} LIMIT ?`
return this.db.query(sql).all(...(o.root !== undefined ? [o.root] : []), match, ...roles, ...ex, o.limit ?? 300) as Hit[]
}
session(id: string): (SessionRow & { count: number }) | undefined {
return (this.db.query("SELECT s.*, (SELECT count(*) FROM messages m WHERE m.session_id = s.id AND m.kind = 'message') AS count FROM sessions s WHERE s.id = ?").get(id) ?? undefined) as
| (SessionRow & { count: number })
| undefined
}
/** Nothing was ever said in it: no rows at all — a turn undone still counts as said,
* since /redo could bring it back. */
isEmpty(sessionId: string): boolean {
return !this.db.query("SELECT 1 FROM messages WHERE session_id = ? LIMIT 1").get(sessionId)
}
/** Visible messages with their ids, in order. */
rows(sessionId: string): Row[] {
return this.db.query("SELECT id, role, json, created FROM messages WHERE session_id = ? AND kind = 'message' ORDER BY id").all(sessionId) as Row[]
}
search(query: string, limit = 20): { session_id: string; id: number }[] {
return this.db
.query(
"SELECT m.session_id, m.id FROM messages_fts f JOIN messages m ON m.id = f.rowid WHERE messages_fts MATCH ? ORDER BY rank LIMIT ?",
)
.all(query, limit) as { session_id: string; id: number }[]
}
}
/** A compaction summary replayed as a user turn and an acknowledgement (LLeMbas compaction). */
export function summaryTurns(summary: string): Message[] {
return [
{ role: "user", parts: [{ type: "text", text: `The earlier part of this conversation was compacted. Its summary:\n\n${summary}` }] },
{ role: "assistant", parts: [{ type: "text", text: "Understood — I will carry on from that summary." }] },
]
}
+66
View File
@@ -0,0 +1,66 @@
// Session titles. A session is named from its first prompt the moment it is sent, so a session
// that dies mid-reply still has a name; once the first reply is in, the model may write a better
// one (OpenCode and Hermes both do — a prompt's first line is often "ok so" or a pasted error).
import type { Client, Message } from "../provider/types.ts"
const MAX = 80
/** The first line of a prompt worth reading, cut at a word to at most 80 characters. */
export function promptTitle(text: string): string {
const line =
text
.split("\n")
.map((l) => l.replace(/^[\s#>*\-`]+/, "").replace(/\s+/g, " ").trim())
.find((l) => l.length > 0) ?? ""
return clip(line)
}
function clip(s: string): string {
if (s.length <= MAX) return s
const cut = s.slice(0, MAX - 1)
const space = cut.lastIndexOf(" ")
return `${space > MAX / 2 ? cut.slice(0, space) : cut}…`
}
const PROMPT = `Write a title for the conversation below: 3 to 7 words that say what it is about, in the language the user wrote in.
Reply with the title only — no quotes, no full stop at the end, no preamble.
The user's first message:
{{prompt}}
The first reply:
{{reply}}`
/** A title from the model, or undefined when it gave nothing usable. */
export async function modelTitle(client: Client, prompt: string, reply: string, signal?: AbortSignal): Promise<string | undefined> {
const text = PROMPT.replace("{{prompt}}", () => prompt.slice(0, 2000)).replace("{{reply}}", () => reply.slice(0, 2000) || "(none yet)")
let out = ""
for await (const ev of client.stream({ system: "", messages: [{ role: "user", parts: [{ type: "text", text }] }], tools: [], effort: null, signal }))
if (ev.type === "finish") out = ev.message.parts.map((p) => (p.type === "text" ? p.text : "")).join("")
return cleanTitle(out)
}
export function cleanTitle(raw: string): string | undefined {
const line = raw
.replace(/<think>[\s\S]*?<\/think>/g, "")
.split("\n")
.map((l) => l.trim())
.find(Boolean)
if (!line) return undefined
const t = line
.replace(/^(\*\*)?title(\*\*)?\s*:\s*/i, "")
.replace(/^#+\s*/, "")
.replace(/^["'“„*`]+|["'”*`]+$/g, "")
.replace(/[.。]$/, "")
.trim()
return t ? clip(t) : undefined
}
/** The text of the first assistant reply in a conversation. */
export function firstReply(messages: Message[]): string {
for (const m of messages) if (m.role === "assistant") {
const t = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("").trim()
if (t) return t
}
return ""
}
+135
View File
@@ -0,0 +1,135 @@
// One entry per prompt: where the conversation and the working tree were before it and after it.
// /undo takes the files the turn changed back to how they were and drops the turn from the
// conversation; /redo reverses that. A new prompt clears what could be redone.
import type { Snapshots } from "../git/snapshot.ts"
import type { Message } from "../provider/types.ts"
import type { Engine, PromptMeta } from "./engine.ts"
import type { Store } from "./store.ts"
interface Turn {
prompt: string
/** engine.messages.length before the prompt. */
at: number
before?: string
after?: string
firstRow: number
lastRow: number
}
interface Undone {
turn: Turn
/** The working tree when it was undone — what /redo puts back. */
current?: string
messages: Message[]
}
export interface UndoResult {
prompt: string
files: string[]
}
export class Turns {
/** The prompt running now: where the tree was before it, and its turn id. */
current?: { before?: string; turnId?: string }
private done: Turn[] = []
private undone: Undone[] = []
/** Tree at the first prompt of the session: the base of /diff. */
base?: string
constructor(
private engine: Engine,
private snaps: Snapshots | undefined,
private store?: Store,
) {}
get canUndo() {
return this.done.length > 0
}
get canRedo() {
return this.undone.length > 0
}
/** Run a prompt with a snapshot either side. Every turn has an id: the one it was given
* (the web UI's message id), or a new one — carried on its `task` and `prompt` events. */
async prompt(text: string, extra: (string | import("../provider/types.ts").ImagePart)[] = [], shown = text, meta: PromptMeta = {}) {
this.undone = []
const before = this.snaps?.track()
this.base ??= before
const turnId = meta.turnId || crypto.randomUUID()
const turn: Turn = { prompt: text, at: this.engine.messages.length, before, firstRow: this.engine.lastRowId + 1, lastRow: 0 }
this.current = { ...turn, turnId }
this.engine.o.bus.emit({ type: "task", state: "start", turnId })
try {
return await this.engine.prompt(text, extra, shown, { ...meta, turnId })
} finally {
this.current = undefined
turn.after = this.snaps?.track()
turn.lastRow = this.engine.lastRowId
this.done.push(turn)
this.engine.o.bus.emit({ type: "task", state: "end", turnId })
}
}
/** A message the user sent while a task runs. False when no task runs: send it as a prompt
* instead. */
steer(text: string, id?: string): boolean {
if (!this.current) return false
this.engine.enqueue(text, id)
return true
}
/** What was sent during the task and never reached the model (queue mode, a turn that ended
* first, or Esc): for the caller to send next, or to give back to the user. */
takeInbox(): string[] {
return this.engine.takeInbox()
}
undo(): UndoResult | undefined {
const turn = this.done.pop()
if (!turn) return undefined
const current = this.snaps?.track()
let files: string[] = []
if (this.snaps && turn.before && current) {
files = this.snaps.changed(turn.before, current)
this.snaps.restore(turn.before, files)
}
const messages = this.engine.messages.splice(turn.at)
if (this.store && this.engine.sessionId && turn.lastRow >= turn.firstRow) this.store.setKind(this.engine.sessionId, turn.firstRow, turn.lastRow, "reverted")
this.undone.push({ turn, current, messages })
return { prompt: turn.prompt, files }
}
redo(): UndoResult | undefined {
const u = this.undone.pop()
if (!u) return undefined
let files: string[] = []
if (this.snaps && u.turn.before && u.current) {
files = this.snaps.changed(u.turn.before, u.current)
this.snaps.restore(u.current, files)
}
this.engine.messages.push(...u.messages)
if (this.store && this.engine.sessionId && u.turn.lastRow >= u.turn.firstRow) this.store.setKind(this.engine.sessionId, u.turn.firstRow, u.turn.lastRow, "message")
this.done.push(u.turn)
return { prompt: u.turn.prompt, files }
}
/** Everything changed since the session's first prompt, as a unified diff. */
diff(): string | undefined {
if (!this.snaps || !this.base) return undefined
const now = this.snaps.track()
return now ? this.snaps.diff(this.base, now) : undefined
}
/** Paths changed since the session's first prompt. */
touched(): string[] {
if (!this.snaps || !this.base) return []
const now = this.snaps.track()
return now ? this.snaps.changed(this.base, now) : []
}
reset() {
this.done = []
this.undone = []
this.base = undefined
}
}
+104
View File
@@ -0,0 +1,104 @@
// Settings while a session runs: what each one is now, where that comes from, and changing it —
// for this session, in the global config, or in the project's. The list is config/settings.ts;
// what a change does to the running session is the app's (`apply`).
import { mkdirSync, writeFileSync } from "node:fs"
import { assertProjectFile } from "./project/safe.ts"
import { dirname, join } from "node:path"
import { findSetting, globalConfigFile, legacyInstructionFiles, parseValue, readRaw, SettingError, SETTINGS, show, sourceOf, writeKey, type Entry, type Scope, type SettingDef } from "./config/settings.ts"
export interface SettingsHost {
/** The project's config.yaml, when the project is trusted (else project scope is refused). */
projectFile?: string
/** The value in force now, where the session holds it (the model, the mode). */
live(key: string): unknown
/** Make it so for the running session. Throws when the value is not usable (no such model). */
apply(key: string, value: unknown): void
/** Checks that need the session: the model exists, the effort is one it takes. */
check?(key: string, value: unknown): void
/** A global setting that lives elsewhere (personalization, logged in, is the account's on the
* instance): handle it there and say where, or undefined to write config.yaml as usual. */
remote?(key: string, value: unknown): string | undefined
}
export interface SetResult {
def: SettingDef
value: unknown
scope: Scope
/** For whoever asked: what changed, and when it counts. */
message: string
}
export class Settings {
/** Changed for this session only. */
readonly session = new Map<string, unknown>()
/** Checks a front end adds (the TUI knows which skins exist). */
readonly checks = new Map<string, (value: unknown) => void>()
constructor(private host: SettingsHost) {}
private files() {
return { global: readRaw(globalConfigFile()), project: this.host.projectFile ? readRaw(this.host.projectFile) : undefined }
}
get(key: string): Entry {
const def = findSetting(key)
if (!def) throw new SettingError(`${key} is not a setting — ${SETTINGS.map((s) => s.key).join(", ")}`)
const f = this.files()
// A key a project may not set is not set by a project's file, whatever is written there:
// loading ignores it (load.ts GLOBAL_ONLY), so it is not shown as the value either.
const { value, source } = sourceOf(key, this.session, def.scopes.includes("project") ? f.project : undefined, f.global)
// A list under `instructions` is an old config's instruction files, not text: it is
// no custom instructions, and the files are read as instruction_files.
const files = key === "instructions" && source !== "session" ? legacyInstructionFiles(value) : undefined
if (files) return { def, value: undefined, source: "default", note: `instructions holds a list of files (${files.join(", ")}), from an older config: they are read as instruction_files, and setting instructions moves them there` }
const live = this.host.live(key)
return { def, value: live !== undefined ? live : value, source }
}
list(): Entry[] {
return SETTINGS.map((s) => this.get(s.key))
}
set(key: string, raw: unknown, scope: Scope = "session"): SetResult {
const def = findSetting(key)
if (!def) throw new SettingError(`${key} is not a setting — ${SETTINGS.map((s) => s.key).join(", ")}`)
if (!def.scopes.includes(scope)) throw new SettingError(`${key} can be set for: ${def.scopes.join(", ")}`)
if (scope === "project" && !this.host.projectFile) throw new SettingError("project settings need a trusted project")
const value = parseValue(def, raw)
try {
this.host.check?.(key, value)
this.checks.get(key)?.(value)
} catch (e) {
throw new SettingError((e as Error).message)
}
const notes: string[] = []
const elsewhere = scope === "global" ? this.host.remote?.(key, value) : undefined
if (scope === "session") this.session.set(key, value)
else if (elsewhere) {
notes.push(elsewhere)
this.session.set(key, value)
} else {
const file = scope === "global" ? globalConfigFile() : this.host.projectFile!
mkdirSync(dirname(file), { recursive: true })
if (scope === "project") assertProjectFile(file, dirname(dirname(file)))
notes.push(...writeKey(file, key.split("."), value, (f, text) => writeFileSync(f, text)))
this.session.delete(key)
}
if (!def.reload) this.host.apply(key, value)
const where = scope === "session" ? "for this session" : elsewhere ? "for your account" : scope === "global" ? "in the global config" : "in this project's config"
let message = `${key} = ${show(value)} ${where}`
if (def.reload) message += "; it takes effect from the next start (/reload)"
for (const n of notes) message += `; ${n}`
// A global value under a project's own is set, but the project's wins here from the next start.
if (scope === "global") {
const p = this.files().project
const shadow = p && key.split(".").reduce<unknown>((o, k) => (o && typeof o === "object" ? (o as Record<string, unknown>)[k] : undefined), p)
if (shadow !== undefined) message += ` — this project's config sets ${show(shadow)}, which wins here from the next start`
}
return { def, value, scope, message }
}
}
export const projectConfigFile = (dir: string) => join(dir, "config.yaml")
+224
View File
@@ -0,0 +1,224 @@
// Skills, after Hermes Agent (tools/skills_tool.py, agent/skill_utils.py, agent/prompt_builder.py,
// agent/skill_commands.py; MIT, © Nous Research): a directory with a SKILL.md (YAML frontmatter
// with a name and a description, then instructions) and optional references/, templates/,
// scripts/ and assets/. Only the name and the first line of the description go into the system
// prompt; the model loads the rest with skill_view when a task calls for it.
//
// Where they are found, a same-named skill in an earlier place winning:
// .agent/skills/ (a trusted project) → ~/.config/lembas/skills/ → config skills.external_dirs
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"
import { platform } from "node:os"
import { basename, join, relative } from "node:path"
import { parse } from "yaml"
import { expandHome, paths } from "../config/paths.ts"
export interface Skill {
name: string
description: string
category?: string
tags: string[]
related: string[]
dir: string
file: string
source: "project" | "global" | "external"
}
export const SUPPORT_DIRS = ["references", "templates", "scripts", "assets"] as const
const SKIP = new Set([".git", ".github", ".archive", ".venv", "venv", "node_modules", "site-packages", "__pycache__", ...SUPPORT_DIRS])
export const NAME = /^[a-z0-9][a-z0-9._-]*$/
export const MAX_NAME = 64
export const MAX_DESCRIPTION = 1024
export const INDEX_DESCRIPTION = 60
export const MAX_FILE = 100_000
const OS: Record<string, string> = { linux: "linux", darwin: "macos", win32: "windows" }
export const globalSkillsDir = () => join(paths.config, "skills")
/** The frontmatter and the body. Malformed YAML falls back to `key: value` lines, as Hermes does. */
export function splitFrontmatter(raw: string): { meta: Record<string, unknown>; body: string } | undefined {
const text = raw.replace(/^/, "").replace(/\r\n/g, "\n")
const m = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(text)
if (!m) return undefined
let meta: Record<string, unknown> = {}
try {
const y = parse(m[1]!)
if (y && typeof y === "object" && !Array.isArray(y)) meta = y as Record<string, unknown>
} catch {
for (const line of m[1]!.split("\n")) {
const kv = /^([\w-]+):\s*(.*)$/.exec(line)
if (kv) meta[kv[1]!] = kv[2]!.replace(/^["']|["']$/g, "")
}
}
return { meta, body: text.slice(m[0].length) }
}
const list = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : typeof v === "string" && v ? v.split(",").map((s) => s.trim()).filter(Boolean) : [])
function readSkill(file: string, root: string, source: Skill["source"]): Skill | undefined {
let raw: string
try {
raw = readFileSync(file, "utf8").slice(0, 4000)
} catch {
return undefined
}
const fm = splitFrontmatter(raw)
if (!fm) return undefined
const dir = join(file, "..")
const hermes = ((fm.meta.metadata as any)?.hermes ?? {}) as Record<string, unknown>
// A skill for another OS is not offered.
const platforms = list(fm.meta.platforms)
if (platforms.length && !platforms.map((p) => p.toLowerCase()).includes(OS[platform()] ?? platform())) return undefined
const rel = relative(root, dir).split("/")
// The name goes into the system prompt and becomes a command: one line, the length of a name.
const name = String(fm.meta.name ?? basename(dir)).replace(/\s+/g, " ").trim()
if (!name || name.length > MAX_NAME || /[<>]/.test(name)) return undefined
return {
name,
description: String(fm.meta.description ?? "").trim().replace(/\s+/g, " "),
category: rel.length > 1 ? rel.slice(0, -1).join("/") : typeof hermes.category === "string" ? hermes.category : undefined,
tags: list(fm.meta.tags ?? hermes.tags),
related: list(fm.meta.related_skills ?? hermes.related_skills),
dir,
file,
source,
}
}
function scan(root: string, source: Skill["source"], dir = root, depth = 0): Skill[] {
if (!existsSync(dir) || depth > 3) return []
if (depth > 0 && existsSync(join(dir, "SKILL.md"))) {
const s = readSkill(join(dir, "SKILL.md"), root, source)
return s ? [s] : []
}
let names: string[] = []
try {
names = readdirSync(dir).sort()
} catch {
return []
}
return names.filter((n) => !SKIP.has(n) && !n.startsWith(".")).flatMap((n) => {
const p = join(dir, n)
try {
return statSync(p).isDirectory() ? scan(root, source, p, depth + 1) : []
} catch {
return []
}
})
}
export interface SkillSources {
/** .agent of a trusted project. */
projectDir?: string
external?: string[]
disabled?: string[]
}
/** Every skill, a same-named one from an earlier place winning. */
export function loadSkills(src: SkillSources = {}): Skill[] {
const byName = new Map<string, Skill>()
const places: [string, Skill["source"]][] = [
...(src.projectDir ? [[join(src.projectDir, "skills"), "project"] as [string, Skill["source"]]] : []),
[globalSkillsDir(), "global"],
...(src.external ?? []).map((d) => [expandHome(d), "external"] as [string, Skill["source"]]),
]
for (const [dir, source] of places) for (const s of scan(dir, source)) if (!byName.has(s.name) && !src.disabled?.includes(s.name)) byName.set(s.name, s)
return [...byName.values()].sort((a, b) => (a.category ?? "").localeCompare(b.category ?? "") || a.name.localeCompare(b.name))
}
export function findSkill(skills: Skill[], name: string): Skill | undefined {
const n = name.trim().toLowerCase()
return skills.find((s) => s.name.toLowerCase() === n) ?? skills.find((s) => slug(s.name) === slug(n))
}
/** The name as a slash command: lowercase, `_` and spaces as `-`. */
export const slug = (name: string) => name.toLowerCase().replace(/[\s_]+/g, "-").replace(/[^\w-]/g, "").replace(/-+/g, "-")
/** Supporting files, relative to the skill's directory. */
export function linkedFiles(dir: string): string[] {
const out: string[] = []
const walk = (d: string, depth: number) => {
if (!existsSync(d) || depth > 4) return
let names: string[]
try {
names = readdirSync(d).sort()
} catch {
return
}
for (const n of names) {
const p = join(d, n)
// A dangling link or an unreadable entry is left out, not a reason to fail the skill.
try {
if (!within(dir, p)) continue
if (statSync(p).isDirectory()) walk(p, depth + 1)
else out.push(relative(dir, p))
} catch {}
}
}
for (const sub of SUPPORT_DIRS) walk(join(dir, sub), 0)
return out
}
const short = (d: string) => (d.length > INDEX_DESCRIPTION ? d.slice(0, INDEX_DESCRIPTION - 3) + "..." : d)
/** The list for the system prompt's skills block (harness/prompts/blocks/skills.md), by category. */
export function skillLines(skills: Skill[]): string {
const groups = new Map<string, Skill[]>()
for (const s of skills) groups.set(s.category ?? "general", [...(groups.get(s.category ?? "general") ?? []), s])
return [...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.flatMap(([cat, ss]) => [` ${cat}:`, ...ss.map((s) => ` - ${s.source === "project" ? "[project] " : ""}${s.name}: ${short(s.description)}`)])
.join("\n")
}
/** The user turn a /skill command sends (Hermes' wording, adapted). */
export function skillMessage(s: Skill, instruction: string): string {
const body = splitFrontmatter(readFileSync(s.file, "utf8"))?.body.trim() ?? ""
const files = linkedFiles(s.dir)
return [
`[IMPORTANT: The user has invoked the "${s.name}" skill: follow its instructions. The full skill is below.]`,
"",
body,
"",
`[Skill directory: ${s.dir}]`,
"Resolve relative paths in this skill (scripts/…, templates/…) against that directory.",
...(files.length ? ["", "[Supporting files — load one with skill_view(name, file_path), or run a script by its absolute path:]", ...files.map((f) => `- ${f}`)] : []),
...(instruction.trim() ? ["", `The user's instruction with it: ${instruction.trim()}`] : []),
].join("\n")
}
/** Whether a path, symlinks followed, is inside a directory (Hermes' validate_within_dir). A path
* that does not exist yet is judged by its nearest existing parent. */
export function within(dir: string, p: string): boolean {
let head = p
const rest: string[] = []
for (;;) {
try {
const real = join(realpathSync(head), ...rest)
const base = realpathSync(dir)
return real === base || real.startsWith(base + "/")
} catch {
const up = join(head, "..")
if (up === head) return false
rest.unshift(basename(head))
head = up
}
}
}
/** Read a file inside a skill's directory; never outside it. */
export function skillFile(s: Skill, rel: string): string {
if (!rel || rel.startsWith("/") || /^[A-Za-z]:/.test(rel) || rel.split(/[\\/]/).includes("..")) throw new Error(`"${rel}" is not a path inside the skill`)
const p = join(s.dir, rel)
if (existsSync(p) && !within(s.dir, p)) throw new Error(`"${rel}" leads out of the skill's directory`)
if (!existsSync(p) || !statSync(p).isFile()) throw new Error(`${s.name} has no ${rel}. Its files: ${linkedFiles(s.dir).join(", ") || "(none)"}`)
return readFileSync(p, "utf8")
}
/** For write and apply_patch: a SKILL.md written outside every place skills are loaded from is not
* a skill. Models asked to "save a skill" do this; the note sends them to skill_manage. */
export function straySkillNote(paths: string[], ctx: { skills?: { list(): Skill[]; projectDir?: string } }): string {
if (!ctx.skills) return ""
const roots = [globalSkillsDir(), ...(ctx.skills.projectDir ? [join(ctx.skills.projectDir, "skills")] : [])]
const stray = paths.filter((p) => /skill.*\.md$|^skill\.md$/i.test(basename(p)) && !roots.some((r) => p.startsWith(r + "/")))
return stray.length ? `\nNote: a SKILL.md here is an ordinary file — LLeMbas CLI does not load it as a skill. To save a skill, use skill_manage (create).` : ""
}
+175
View File
@@ -0,0 +1,175 @@
// apply_patch: the "*** Begin Patch" format (OpenCode tool/apply_patch.txt, parser in patch.ts),
// which GPT-family models are trained on, or a unified diff (unidiff.ts, from LLeMbas's file_edit),
// which every model has seen. Context lines locate each change, so unlike edit it does not demand
// the file was read first — a wrong context is refused instead.
import { mkdir, rm, stat } from "node:fs/promises"
import { dirname } from "node:path"
import { createTwoFilesPatch } from "diff"
import { z } from "zod"
import { derive, joinBom, parse, type Hunk } from "./patch.ts"
import { applyUnified, isUnified, parseUnified, UnidiffError, type UniFile } from "./unidiff.ts"
import { straySkillNote } from "../skill/index.ts"
import { absPath, defineTool, readText, showPath, ToolError, type ToolContext } from "./tool.ts"
import { diffStat } from "./write.ts"
import { described } from "../harness.ts"
function hunksOf(text: string): Hunk[] {
try {
return [...parse(text)]
} catch {
return []
}
}
function uniFiles(text: string): UniFile[] {
try {
return parseUnified(text)
} catch {
return []
}
}
/** The files a patch names, as given: for the permission request, before anything is read. */
function namedFiles(a: { patch: string; path?: string }): string[] {
if (isUnified(a.patch)) return [...new Set(uniFiles(a.patch).map((f) => f.path ?? a.path).filter((p): p is string => p !== undefined))]
return hunksOf(a.patch).flatMap((h) => (h.type === "update" && h.movePath ? [h.path, h.movePath] : [h.path]))
}
type Op = { kind: "write"; path: string; content: string; before: string } | { kind: "delete"; path: string; before: string }
function opsOf(original: Map<string, string | null>, current: Map<string, string | null>): Op[] {
const ops: Op[] = []
for (const [path, now] of current) {
const before = original.get(path) ?? null
if (now === before) continue
if (now === null) ops.push({ kind: "delete", path, before: before! })
else ops.push({ kind: "write", path, content: now, before: before ?? "" })
}
return ops
}
/** A unified diff: each file's hunks applied to it in memory, all or nothing. */
async function planUnified(a: { patch: string; path?: string }, ctx: ToolContext): Promise<Op[]> {
let files: UniFile[]
try {
files = parseUnified(a.patch)
} catch (e) {
throw new ToolError(`${(e as Error).message} Nothing was applied.`)
}
const original = new Map<string, string | null>()
const current = new Map<string, string | null>()
for (const f of files) {
const path = f.path ?? a.path
if (!path) throw new ToolError("The diff names no file: give `path`, or start it with --- a/<file> and +++ b/<file> lines. Nothing was applied.")
const abs = absPath(path, ctx)
if (!current.has(abs)) {
const text = await stat(abs).then(() => readText(abs), () => null)
original.set(abs, text)
current.set(abs, text)
}
const text = current.get(abs)!
if (f.remove) {
if (text === null) throw new ToolError(`${path} does not exist. Nothing was applied.`)
current.set(abs, null)
continue
}
if (text === null && !f.create) throw new ToolError(`${path} does not exist; a diff that creates it starts with --- /dev/null. Nothing was applied.`)
if (text !== null && f.create) throw new ToolError(`${path} already exists; diff it against its current content instead. Nothing was applied.`)
// A byte-order mark is not part of the first line the hunk's context names.
const bom = text?.startsWith("\uFEFF") ?? false
try {
current.set(abs, joinBom(applyUnified(bom ? text!.slice(1) : (text ?? ""), f.hunks), bom))
} catch (e) {
if (e instanceof UnidiffError) throw new ToolError(`${path}: ${e.message}`)
throw e
}
}
return opsOf(original, current)
}
async function planPatch(a: { patch: string; path?: string }, ctx: ToolContext): Promise<Op[]> {
if (isUnified(a.patch)) return planUnified(a, ctx)
let hunks: Hunk[]
try {
hunks = [...parse(a.patch)]
} catch (e) {
throw new ToolError(`The patch could not be parsed: ${(e as Error).message}`)
}
// Work everything out first; write nothing until every hunk is known to apply. Each file's
// content is followed through the patch, so a file named twice gets both changes, in order.
const original = new Map<string, string | null>() // on disk before; null: did not exist
const current = new Map<string, string | null>()
const load = async (abs: string) => {
if (!current.has(abs)) {
const text = await stat(abs).then(() => readText(abs), () => null)
original.set(abs, text)
current.set(abs, text)
}
return current.get(abs)!
}
for (const h of hunks) {
const abs = absPath(h.path, ctx)
const text = await load(abs)
if (h.type === "add") {
if (text !== null) throw new ToolError(`${h.path} already exists; use Update File to change it. Nothing was applied.`)
current.set(abs, h.contents.endsWith("\n") ? h.contents : h.contents + "\n")
} else if (h.type === "delete") {
if (text === null) throw new ToolError(`${h.path} does not exist. Nothing was applied.`)
current.set(abs, null)
} else {
if (text === null) throw new ToolError(`${h.path} does not exist. Nothing was applied.`)
let next
try {
next = derive(h.path, h.chunks, text)
} catch (e) {
throw new ToolError(`${(e as Error).message}\nRead the file again and write the hunk from what is there. Nothing was applied.`)
}
const to = h.movePath ? absPath(h.movePath, ctx) : abs
if (to !== abs) {
if ((await load(to)) !== null) throw new ToolError(`${h.movePath} already exists; a move does not overwrite a file. Nothing was applied.`)
current.set(abs, null)
}
current.set(to, joinBom(next.content, next.bom))
}
}
return opsOf(original, current)
}
export const applyPatchTool = defineTool({
...described("apply_patch"),
access: "edit",
schema: z.object({ patch: z.string().min(1), path: z.string().optional() }),
permission: (a, ctx) => {
const files = namedFiles(a).map((p) => absPath(p, ctx))
return { permission: "edit", class: "write", patterns: files.length ? files.map((f) => showPath(f, ctx)) : ["*"], paths: files }
},
async preview(a, ctx) {
return (await planPatch(a, ctx))
.map((op) => {
const shown = showPath(op.path, ctx)
return createTwoFilesPatch(shown, op.kind === "delete" ? "/dev/null" : shown, op.before, op.kind === "delete" ? "" : op.content, "", "", { context: 3 })
})
.join("\n")
},
async run(a, ctx) {
const ops = await planPatch(a, ctx)
const patches: string[] = []
for (const op of ops) {
const shown = showPath(op.path, ctx)
if (op.kind === "delete") {
await rm(op.path)
patches.push(createTwoFilesPatch(shown, "/dev/null", op.before, "", "", "", { context: 3 }))
continue
}
await mkdir(dirname(op.path), { recursive: true })
await Bun.write(op.path, op.content)
ctx.readFiles.add(op.path)
ctx.fileStamps.set(op.path, (await stat(op.path)).mtimeMs)
patches.push(createTwoFilesPatch(shown, shown, op.before, op.content, "", "", { context: 3 }))
}
const all = patches.join("\n")
const { added, removed } = diffStat(all)
const names = ops.map((o) => showPath(o.path, ctx)).join(", ")
return { output: `Applied to ${names} (+${added} −${removed}).${straySkillNote(ops.filter((o) => o.kind === "write").map((o) => o.path), ctx)}`, title: `${names} +${added} −${removed}`, meta: { diff: all } }
},
})
+147
View File
@@ -0,0 +1,147 @@
import { spawn } from "node:child_process"
import { z } from "zod"
import { getJob, killJob, listJobs, startJob } from "./jobs.ts"
import { absPath, defineTool, showPath, ToolError, truncate } from "./tool.ts"
import { duration } from "../duration.ts"
import { described } from "../harness.ts"
// git run by the model reads a repository's own config, which can name programs to run (an external
// diff, an fsmonitor, a pager) — and "git diff", "git show", "git status" are allowed unasked. These
// override that for every git started here; an embedded bare repository is not discovered either.
const GIT_SAFE: [string, string][] = [
["core.fsmonitor", "false"],
["diff.external", ""],
["core.pager", "cat"],
["safe.bareRepository", "explicit"],
]
const ENV = {
...process.env,
// A command can tell it runs under the agent.
LEMBAS: "1",
GIT_PAGER: "cat",
PAGER: "cat",
GIT_EDITOR: "true",
EDITOR: "true",
GIT_CONFIG_COUNT: String(GIT_SAFE.length),
...Object.fromEntries(GIT_SAFE.flatMap(([k, v], i) => [[`GIT_CONFIG_KEY_${i}`, k], [`GIT_CONFIG_VALUE_${i}`, v]])),
}
export const bashTool = defineTool({
...described("bash"),
purpose: "description",
access: "bash",
schema: z.object({
command: z.string(),
description: z.string().optional(),
cwd: z.string().optional(),
timeout: z.number().int().positive().max(600_000).optional(),
background: z.boolean().optional(),
}),
permission: (a, ctx) => {
const cwd = absPath(a.cwd ?? ".", ctx)
return { permission: "bash", class: "execute", patterns: [a.command], command: a.command, paths: [cwd] }
},
async run(a, ctx) {
const cwd = absPath(a.cwd ?? ".", ctx)
if (a.background) {
const job = startJob(a.command, cwd, ENV)
await Bun.sleep(300)
const early = job.output
job.read = early.length
return {
output: `Started in the background as ${job.id} (pid ${job.child.pid}).${job.exit !== undefined ? ` It has already exited with ${job.exit}.` : ""}${early ? `\nFirst output:\n${truncate(early, 4000)}` : ""}\nRead more with bash_output, stop it with bash_kill.`,
title: `${a.description ?? a.command.split("\n")[0]!.slice(0, 80)} · background ${job.id}`,
meta: { job: job.id },
}
}
const timeout = a.timeout ?? ctx.bashTimeoutMs
// Own process group, so a timeout or cancel takes the whole tree down.
const child = spawn("bash", ["-c", `exec 2>&1\n${a.command}`], {
cwd,
detached: true,
stdio: ["ignore", "pipe", "pipe"],
env: ENV,
})
let out = ""
child.stdout!.on("data", (d: Buffer) => {
if (out.length < 2_000_000) out += d.toString()
})
let killed = ""
const kill = (why: string) => {
killed = why
try {
process.kill(-child.pid!, "SIGTERM")
setTimeout(() => {
try {
process.kill(-child.pid!, "SIGKILL")
} catch {}
}, 2000)
} catch {}
}
const timer = setTimeout(() => kill(`timed out after ${duration(timeout, true)}`), timeout)
const onAbort = () => kill("cancelled")
ctx.signal.addEventListener("abort", onAbort, { once: true })
const code: number | null = await new Promise((res) => child.on("close", (c) => res(c)))
clearTimeout(timer)
ctx.signal.removeEventListener("abort", onAbort)
const status = killed ? killed : `exit ${code ?? "?"}`
return {
output: `${truncate(out.trimEnd()) || "(no output)"}\n\n[${status}]`,
title: `${a.description ?? a.command.split("\n")[0]!.slice(0, 80)} · ${status}`,
isError: killed !== "" || code !== 0,
meta: { exit: code, cwd: showPath(cwd, ctx) },
}
},
})
export const bashOutputTool = defineTool({
...described("bash_output"),
purpose: false,
access: "bash_output",
schema: z.object({ id: z.string(), wait: z.number().min(0).max(60).optional() }),
permission: () => ({ permission: "bash_output", class: "read", patterns: ["*"] }),
async run(a, ctx) {
const job = getJob(a.id)
if (!job) throw new ToolError(`No background job ${a.id}.`)
const until = Date.now() + (a.wait ?? 0) * 1000
while (Date.now() < until && job.exit === undefined && job.output.length === job.read && !ctx.signal.aborted) await Bun.sleep(200)
const fresh = job.output.slice(job.read)
job.read = job.output.length
const state = job.exit === undefined ? "still running" : `exited with ${job.exit}`
return { output: `${truncate(fresh.trimEnd()) || "(no new output)"}\n\n[${job.id} ${state}]`, title: `${job.id} · ${state}` }
},
})
export const bashKillTool = defineTool({
...described("bash_kill"),
purpose: false,
access: "bash_kill",
schema: z.object({ id: z.string() }),
permission: () => ({ permission: "bash_kill", class: "execute", patterns: ["*"] }),
async run(a) {
const job = getJob(a.id)
if (!job) throw new ToolError(`No background job ${a.id}.`)
if (job.exit !== undefined) return { output: `${job.id} had already exited with ${job.exit}.`, title: `${job.id} · already exited` }
killJob(job)
return { output: `Stopped ${job.id} (${job.command.split("\n")[0]}).`, title: `${job.id} · stopped` }
},
})
export const bashListTool = defineTool({
...described("bash_list"),
purpose: false,
access: "bash_output",
schema: z.object({}),
permission: () => ({ permission: "bash_output", class: "read", patterns: ["*"] }),
async run() {
const jobs = listJobs()
if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" }
const now = Date.now()
const lines = jobs.map((j) => {
const state = j.exit === undefined ? "running" : `exited with ${j.exit}`
const unread = j.output.length > j.read ? " · new output" : ""
return `${j.id} · ${state} · ${duration(now - j.started, true)}${unread} · ${j.command.split("\n")[0]!.slice(0, 120)}`
})
return { output: lines.join("\n"), title: `${jobs.length} jobs` }
},
})
+48
View File
@@ -0,0 +1,48 @@
import { stat } from "node:fs/promises"
import { createTwoFilesPatch } from "diff"
import { z } from "zod"
import { replace } from "./replace-text.ts"
import { absPath, defineTool, readText, showPath, ToolError, type ToolContext } from "./tool.ts"
import { diffStat, guardExisting } from "./write.ts"
import { described } from "../harness.ts"
async function planEdit(a: { path: string; old: string; new: string; all?: boolean }, ctx: ToolContext) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
if (!(await guardExisting(abs, display, ctx))) throw new ToolError(`${display} does not exist. Use write to create it.`)
const raw = await readText(abs)
const crlf = raw.includes("\r\n")
const before = crlf ? raw.replaceAll("\r\n", "\n") : raw
try {
return { abs, display, before, crlf, after: replace(before, a.old.replaceAll("\r\n", "\n"), a.new.replaceAll("\r\n", "\n"), a.all ?? false) }
} catch (e) {
throw new ToolError(`${display}: ${(e as Error).message}`)
}
}
export const editTool = defineTool({
...described("edit"),
access: "edit",
schema: z.object({
path: z.string(),
old: z.string(),
new: z.string(),
all: z.boolean().optional(),
}),
permission: (a, ctx) => {
const abs = absPath(a.path, ctx)
return { permission: "edit", class: "write", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async preview(a, ctx) {
const { display, before, after } = await planEdit(a, ctx)
return createTwoFilesPatch(display, display, before, after, "", "", { context: 3 })
},
async run(a, ctx) {
const { abs, display, before, after, crlf } = await planEdit(a, ctx)
await Bun.write(abs, crlf ? after.replaceAll("\n", "\r\n") : after)
ctx.fileStamps.set(abs, (await stat(abs)).mtimeMs)
const patch = createTwoFilesPatch(display, display, before, after, "", "", { context: 3 })
const { added, removed } = diffStat(patch)
return { output: `Edited ${display} (+${added} −${removed}).`, title: `${display} +${added} −${removed}`, meta: { diff: patch } }
},
})
+86
View File
@@ -0,0 +1,86 @@
// Background commands started by `bash` with background: true. Each runs in its own process group;
// all of them are killed when LLeMbas CLI exits, however it exits.
import { spawn, type ChildProcess } from "node:child_process"
export interface Job {
id: string
command: string
child: ChildProcess
output: string
/** How much of `output` the model has already been given. */
read: number
exit?: number | null
started: number
}
const MAX_BUFFER = 2_000_000
const exitListeners = new Set<(job: Job) => void>()
/** Be told when a background job ends — how a session is woken by one (after LLeMbas's
* jobs). Returns the way to stop listening. */
export function onJobExit(fn: (job: Job) => void): () => void {
exitListeners.add(fn)
return () => exitListeners.delete(fn)
}
/** What the model is told when a job it started ends: which, how, and the output it has not read. */
export function jobNote(job: Job, maxChars = 4000): string {
const unread = job.output.slice(job.read)
const tail = unread.length > maxChars ? `…${unread.slice(-maxChars)}` : unread
const how = job.exit === null ? "was killed" : `exited with ${job.exit}`
const secs = Math.round((Date.now() - job.started) / 1000)
return `Background job ${job.id} (${job.command.split("\n")[0]!.slice(0, 120)}) ${how} after ${secs}s.${tail.trim() ? `\nOutput you had not read:\n${tail}` : " It printed nothing you had not read."}`
}
const jobs = new Map<string, Job>()
let seq = 0
export function startJob(command: string, cwd: string, env: Record<string, string | undefined>): Job {
const child = spawn("bash", ["-c", `exec 2>&1\n${command}`], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], env })
const job: Job = { id: `job${++seq}`, command, child, output: "", read: 0, started: Date.now() }
child.stdout!.on("data", (d: Buffer) => {
job.output += d.toString()
// Keep the tail when a long-running job talks a lot; the model only ever reads what is new.
if (job.output.length > MAX_BUFFER) {
const cut = job.output.length - MAX_BUFFER
job.output = job.output.slice(cut)
job.read = Math.max(0, job.read - cut)
}
})
child.on("close", (code) => {
job.exit = code
for (const fn of exitListeners) {
try {
fn(job)
} catch {}
}
})
jobs.set(job.id, job)
// Finished jobs beyond the last 50 are forgotten (each keeps up to MAX_BUFFER of output).
const done = [...jobs.values()].filter((j) => j.exit !== undefined)
for (const j of done.slice(0, Math.max(0, done.length - 50))) jobs.delete(j.id)
return job
}
export function getJob(id: string): Job | undefined {
return jobs.get(id)
}
export function killJob(job: Job) {
try {
process.kill(-job.child.pid!, "SIGTERM")
setTimeout(() => {
try {
process.kill(-job.child.pid!, "SIGKILL")
} catch {}
}, 2000).unref()
} catch {}
}
export function listJobs(): Job[] {
return [...jobs.values()]
}
function killAll() {
for (const j of jobs.values()) if (j.exit === undefined) killJob(j)
}
process.once("exit", killAll)
+142
View File
@@ -0,0 +1,142 @@
// The library's tools: notes the agent keeps, and the knowledge bases the user filled.
// Wording of knowledge_search after LLeMbas's tool.knowledge prompt (© Jaroslav Beneš,
// MIT): look there before the web, and read the document before answering from it.
import { z } from "zod"
import { SNIPPET_CHARS } from "../library/store.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
const MAX_GET = 40_000
const MAX_NOTE = 20_000
function lib(ctx: ToolContext) {
if (!ctx.library) throw new ToolError("The library is not available here.")
return ctx.library
}
// Notes are three tools (harness spec v1): reading and writing are never one tool, so a reviewer
// or a read-only helper is given the readers and nothing else.
function noteScopes(ctx: ToolContext) {
const L = lib(ctx)
return { L, scopes: ["global", ...(L.project ? [L.project] : [])] }
}
const scopeLabel = (scope: string) => (scope === "global" ? "global" : "project")
function ownNote(ctx: ToolContext, id: number) {
const { L, scopes } = noteScopes(ctx)
const n = L.lib.note(id)
if (!n || !scopes.includes(n.scope)) throw new ToolError(`There is no note ${id} here.`)
return { L, n }
}
export const notesSearchTool = defineTool({
...described("notes_search"),
access: "notes",
purpose: false,
schema: z.object({
query: z.string().optional(),
}),
permission: (a) => ({ permission: "notes", class: "read", patterns: [a.query || "*"] }),
async run(a, ctx) {
const { L, scopes } = noteScopes(ctx)
const found = L.lib.notes(scopes, a.query, 15)
if (!found.length) return { output: a.query ? `No note matches "${a.query}".` : "No notes yet.", title: "0 notes" }
return { output: found.map((n) => `[${n.id}] ${n.title} (${scopeLabel(n.scope)}, ${new Date(n.updated).toISOString().slice(0, 10)})`).join("\n") + "\n\nRead one with note_view.", title: `${found.length} notes` }
},
})
export const noteViewTool = defineTool({
...described("note_view"),
access: "notes",
purpose: false,
schema: z.object({
id: z.number().int(),
}),
permission: (a) => ({ permission: "notes", class: "read", patterns: [String(a.id)] }),
async run(a, ctx) {
const { n } = ownNote(ctx, a.id)
return { output: `# ${n.title}\n(${scopeLabel(n.scope)}, updated ${new Date(n.updated).toISOString().slice(0, 16).replace("T", " ")})\n\n${n.body}`, title: n.title }
},
})
export const noteManageTool = defineTool({
...described("note_manage"),
access: "notes",
purpose: false,
schema: z.object({
action: z.enum(["create", "edit", "delete"]),
id: z.number().int().optional(),
title: z.string().max(200).optional(),
body: z.string().optional(),
scope: z.enum(["project", "global"]).optional(),
}),
permission: (a) => ({ permission: "notes", class: "interact", patterns: [a.action] }),
async run(a, ctx) {
const { L } = noteScopes(ctx)
if (a.action === "create") {
if (!a.title?.trim() || !a.body?.trim()) throw new ToolError("create needs a title and a body.")
if (a.body.length > MAX_NOTE) throw new ToolError(`A note is at most ${MAX_NOTE} characters; this one is ${a.body.length}.`)
const scope = a.scope === "global" || !L.project ? "global" : L.project
const n = L.lib.addNote(scope, a.title.trim(), a.body)
return { output: `Saved note ${n.id} (${scopeLabel(scope)}).`, title: `+ ${n.title}` }
}
if (a.id === undefined) throw new ToolError(`${a.action} needs the note's id (notes_search finds it).`)
const { n } = ownNote(ctx, a.id)
if (a.action === "delete") {
L.lib.deleteNote(n.id)
return { output: `Deleted note ${n.id}.`, title: `− ${n.title}` }
}
if (a.body !== undefined && a.body.length > MAX_NOTE) throw new ToolError(`A note is at most ${MAX_NOTE} characters.`)
L.lib.editNote(n.id, { title: a.title?.trim() || undefined, body: a.body })
return { output: `Changed note ${n.id}.`, title: `~ ${a.title ?? n.title}` }
},
})
export const knowledgeSearchTool = defineTool({
...described("knowledge_search"),
access: "knowledge",
purpose: false,
requires: "knowledge",
schema: z.object({
query: z.string().min(1),
base: z.string().optional(),
}),
permission: (a) => ({ permission: "knowledge", class: "read", patterns: [a.base ?? "*"] }),
async run(a, ctx) {
const L = lib(ctx)
const bases = a.base ? [a.base] : L.bases
if (a.base && L.bases && !L.bases.includes(a.base)) throw new ToolError(`"${a.base}" is not one of this project's knowledge bases: ${L.bases.join(", ")}`)
let note = ""
let hits
try {
hits = await L.lib.search(a.query, { bases, embedder: L.embedder, signal: ctx.signal })
} catch (e) {
// The embedding model is away: words alone, and said.
note = `\n\n(searched by words only — the embedding model failed: ${(e as Error).message.split("\n")[0]})`
hits = await L.lib.search(a.query, { bases, signal: ctx.signal })
}
if (!hits.length) return { output: `Nothing in the knowledge bases matches "${a.query}".${note}`, title: "0 found" }
const out = hits.map((h) => `[${h.id}] ${h.title} (${h.base}${h.source !== h.title ? ` · ${h.source}` : ""})\n${h.snippet.slice(0, SNIPPET_CHARS)}`).join("\n\n---\n\n")
return { output: `${out}\n\nRead a document whole with knowledge_get(id).${note}`, title: `${hits.length} found` }
},
})
export const knowledgeGetTool = defineTool({
...described("knowledge_get"),
access: "knowledge",
purpose: false,
requires: "knowledge",
schema: z.object({ id: z.number().int(), offset: z.number().int().min(0).optional() }),
permission: () => ({ permission: "knowledge", class: "read", patterns: ["*"] }),
async run(a, ctx) {
const L = lib(ctx)
const d = L.lib.document(a.id)
if (!d || (L.bases && !L.bases.includes(d.base))) throw new ToolError(`There is no document ${a.id} in this project's knowledge bases.`)
if (d.error && !d.text) return { output: `[${d.id}] ${d.title}: no text — ${d.error}`, title: d.title, isError: true }
const from = a.offset ?? 0
const part = d.text.slice(from, from + MAX_GET)
const rest = d.text.length - from - part.length
const tail = rest > 0 ? `\n\n[…${rest.toLocaleString("en")} more characters: knowledge_get(id: ${d.id}, offset: ${from + part.length})]` : d.truncated ? "\n\n[the document was cut at 120,000 characters when it was added]" : ""
return { output: `# ${d.title}\n(${d.base} · ${d.source})\n\n${part}${tail}`, title: `${d.title}${from ? ` @${from}` : ""}` }
},
})
+41
View File
@@ -0,0 +1,41 @@
// The memory tool, after Hermes Agent's (tools/memory_tool.py, MIT, © Nous Research): one tool,
// a single change or a batch applied together, checked against the size limit as a whole.
import { z } from "zod"
import { MemoryStore, type Op } from "../memory/store.ts"
import { defineTool, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
const OpSchema = z.object({
action: z.enum(["add", "replace", "remove"]),
content: z.string().optional(),
new_text: z.string().optional(),
old_text: z.string().optional(),
})
export const memoryTool = defineTool({
...described("memory"),
access: "memory",
schema: z.object({
target: z.enum(["memory", "user", "project"]),
action: z.enum(["add", "replace", "remove"]).optional(),
content: z.string().optional(),
new_text: z.string().optional(),
old_text: z.string().optional(),
operations: z.array(OpSchema).optional(),
}),
permission: (a) => ({ permission: "memory", class: "interact", patterns: [a.target] }),
async run(a, ctx) {
if (a.target === "project" && !ctx.projectMemory) throw new ToolError("This project has no project memory: it is not trusted, or has no .agent directory. Use 'memory' for facts that hold everywhere.")
const store = a.target === "project" ? ctx.projectMemory! : (ctx.memory ?? new MemoryStore())
const norm = (o: z.infer<typeof OpSchema>): Op => ({ action: o.action, content: o.content ?? o.new_text, old_text: o.old_text })
const ops: Op[] = a.operations?.length ? a.operations.map(norm) : a.action ? [norm({ action: a.action, content: a.content, new_text: a.new_text, old_text: a.old_text })] : []
if (!ops.length) throw new ToolError("Nothing to do: give action (with content and/or old_text), or operations.")
const r = store.apply(a.target === "project" ? "memory" : a.target, ops)
const label = a.target === "user" ? "user profile" : a.target === "project" ? "project memory" : "memory"
if (!r.ok) {
const list = r.entries ? `\n\nCurrent entries (${r.usage}):\n${r.entries.map((e, i) => `${i + 1}. ${e}`).join("\n") || "(none)"}` : `\n\n${label}: ${r.usage}`
return { output: r.message + list, title: `${label}: refused`, isError: true }
}
return { output: `${r.message} ${label}: ${r.usage}. Saved — this update is complete; do not repeat it.`, title: `${label} ${ops.map((o) => (o.action === "add" ? "+" : o.action === "remove" ? "−" : "~")).join("")} · ${r.usage.split(" — ")[0]}` }
},
})
+50
View File
@@ -0,0 +1,50 @@
import { stat } from "node:fs/promises"
import { createTwoFilesPatch } from "diff"
import { z } from "zod"
import { replace } from "./replace-text.ts"
import { absPath, defineTool, readText, showPath, ToolError, type ToolContext } from "./tool.ts"
import { diffStat, guardExisting } from "./write.ts"
import { described } from "../harness.ts"
async function planEdits(a: { path: string; edits: { old: string; new: string; all?: boolean }[] }, ctx: ToolContext) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
if (!(await guardExisting(abs, display, ctx))) throw new ToolError(`${display} does not exist. Use write to create it.`)
const raw = await readText(abs)
const crlf = raw.includes("\r\n")
const before = crlf ? raw.replaceAll("\r\n", "\n") : raw
let after = before
for (const [i, e] of a.edits.entries()) {
try {
after = replace(after, e.old.replaceAll("\r\n", "\n"), e.new.replaceAll("\r\n", "\n"), e.all ?? false)
} catch (err) {
throw new ToolError(`${display}: step ${i + 1} of ${a.edits.length} failed — ${(err as Error).message} Nothing was changed.`)
}
}
return { abs, display, before, after, crlf }
}
export const multieditTool = defineTool({
...described("multiedit"),
access: "edit",
schema: z.object({
path: z.string(),
edits: z.array(z.object({ old: z.string(), new: z.string(), all: z.boolean().optional() })).min(1).max(50),
}),
permission: (a, ctx) => {
const abs = absPath(a.path, ctx)
return { permission: "edit", class: "write", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async preview(a, ctx) {
const { display, before, after } = await planEdits(a, ctx)
return createTwoFilesPatch(display, display, before, after, "", "", { context: 3 })
},
async run(a, ctx) {
const { abs, display, before, after, crlf } = await planEdits(a, ctx)
await Bun.write(abs, crlf ? after.replaceAll("\n", "\r\n") : after)
ctx.fileStamps.set(abs, (await stat(abs)).mtimeMs)
const patch = createTwoFilesPatch(display, display, before, after, "", "", { context: 3 })
const { added, removed } = diffStat(patch)
return { output: `Edited ${display}: ${a.edits.length} changes (+${added} −${removed}).`, title: `${display} +${added} −${removed}`, meta: { diff: patch } }
},
})
Loaded 100 of 355 files, more files were not shown because too many files have changed in this diff. Show more