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:
355 files changed
+47028
No files matched your search
+1167
File diff suppressed because it is too large.
Load diff
+340
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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.`
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("\\", "/"))
|
||||
}
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
declare module "*.md" {
|
||||
const text: string
|
||||
export default text
|
||||
}
|
||||
declare module "*/VERSION" {
|
||||
const text: string
|
||||
export default text
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "{}"
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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 : []
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(/&/g, "&").replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, "<").replace(/>/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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ?? "" }
|
||||
}
|
||||
@@ -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("; ")}`)
|
||||
}
|
||||
@@ -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() }))
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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." }] },
|
||||
]
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
@@ -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).` : ""
|
||||
}
|
||||
@@ -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 } }
|
||||
},
|
||||
})
|
||||
@@ -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` }
|
||||
},
|
||||
})
|
||||
@@ -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 } }
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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}` : ""}` }
|
||||
},
|
||||
})
|
||||
@@ -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]}` }
|
||||
},
|
||||
})
|
||||
@@ -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
Reference in new issue
Block a user