lembas -l / --local: everything in the project, nothing written elsewhere

Config, data and state are rooted at <project>/.agent/local/lembas (or
.lembas/), kept out of git; a local directory the repository carries is
refused. The user's logins and connections.yaml are read, never written; the
user's config.yaml is not read. The TUI is started with TMPDIR inside it (the
compiled binary unpacks its native library there) and OpenTUI's cache goes
there too; the agent's commands keep the user's TMPDIR.

An instance serves models, chat, speech and the balance only: the link,
/remote, the service and the hub are off, deletes are not sent, and the server
library, search, fetch, embeddings and personalization are not used.
Self-update and uninstall are refused. hardline_disable is ignored, and tools
treat the local directory as outside the project.

The smoke test runs a whole local session in the compiled binary and checks
that HOME, the XDG directories and TMPDIR gained nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-14 18:16:28 +00:00
1 parent 479f647cc9
commit b75923e724
27 files changed
+636 -105

No files matched your search

+4 -2
View File
@@ -20,7 +20,7 @@
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 { isLocal, 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"
@@ -77,7 +77,8 @@ export function answers(path = hubSocket(), timeoutMs = 1000): Promise<boolean>
/** Connect to the hub, if one runs. */
export function connectHub(path = hubSocket()): Promise<Peer | undefined> {
if (!existsSync(path)) return Promise.resolve(undefined)
// Local mode shares nothing: no hub is reached, and none is held.
if (isLocal() || !existsSync(path)) return Promise.resolve(undefined)
return new Promise((resolve) => {
const sock = connect(path)
const timer = setTimeout(() => (sock.destroy(), resolve(undefined)), 2000)
@@ -138,6 +139,7 @@ export class Hub {
/** Take the socket. False: another hub is alive on it. */
async listen(path = hubSocket()): Promise<boolean> {
if (isLocal()) return false
mkdirSync(paths.state, { recursive: true })
if (existsSync(path)) {
if (await answers(path)) return false
+3
View File
@@ -9,6 +9,7 @@
import { existsSync, readFileSync } from "node:fs"
import { hostname } from "node:os"
import { remoteBlock } from "../config/remote.ts"
import { refusedLocally } from "../project/local.ts"
import type { Mode } from "../config/schema.ts"
import { HARNESS_VERSION } from "../harness.ts"
import { instances, keyFile, rememberProtocols, type Instance } from "../lembas/login.ts"
@@ -124,6 +125,8 @@ export interface LinkOptions {
* `roots: []` and can tell a device that allows no folder yet from one that is not running, and a
* `lembas remote add` or `enable` makes it serve, without a restart. */
export async function runLink(o: LinkOptions = {}): Promise<LinkStatus> {
const local = refusedLocally("the link to a LLeMbas instance (lembas service, /remote)")
if (local) throw new LinkError(local)
const limits = limitsFromConfig({ sharedOnly: o.sharedOnly })
const poll = o.pollMs ?? 2000
const instance = pickInstance(o.instance)
+3 -2
View File
@@ -14,7 +14,7 @@
// 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"
import { isLocal, paths } from "../config/paths.ts"
export const DELETED_MAX = 500
@@ -43,7 +43,8 @@ function write(ids: string[]) {
* directory that cannot be written to only loses the word. */
export function addPendingDeleted(ids: string[]) {
const add = ids.filter(Boolean)
if (!add.length) return
// Local mode tells the instance nothing, now or later.
if (!add.length || isLocal()) 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)]
+5
View File
@@ -18,6 +18,8 @@ import { clampMode, effortOf, markRemote, permissionParams, planParams, planRepl
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
import { dropAttachments, preparePrompt } from "./prompt.ts"
import { connectHub, Hub } from "./hub.ts"
import { isLocal } from "../config/paths.ts"
import { localRefusal, refusedLocally } from "../project/local.ts"
import { addPendingDeleted } from "./pending.ts"
import { REMOTE_META, type Store } from "../session/store.ts"
import { limitsFromConfig, runLink } from "./link.ts"
@@ -193,6 +195,7 @@ export class Share {
}
status(): string {
if (isLocal()) return localRefusal("sharing with the web UI")
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"
@@ -342,6 +345,8 @@ export class Share {
/** Hold the link in this terminal, for /remote while no service runs. Returns why not, if not. */
private async becomeHub(): Promise<string | undefined> {
const local = refusedLocally("/remote")
if (local) return local
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() })
+53 -4
View File
@@ -6,8 +6,10 @@ 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 { isLocal, localRoot } from "./config/paths.ts"
import { SettingError } from "./config/settings.ts"
import { renderJson, renderPlain, terminalAsker } from "./headless.ts"
import { enterLocal, localFor, LocalError, localRefusal, localTmp, LOCAL_FLAGS } from "./project/local.ts"
import { findProject, setTrust, trustOf } from "./project/root.ts"
import { effortsFor } from "./provider/effort.ts"
import { clientFor, ModelError, resolveModel } from "./provider/index.ts"
@@ -18,7 +20,7 @@ import { VERSION } from "./version.ts"
const HELP = `lembas ${VERSION} — terminal coding agent and project manager
Usage:
lembas [-m model] [--mode M] [-c | -s id]
lembas [-l] [-m model] [--mode M] [-c | -s id]
the TUI (-c continues the last session here, -s resumes one)
lembas run [options] "…" one prompt, headless
lembas models [--discover] list configured models
@@ -61,6 +63,21 @@ Usage:
lembas --version
lembas --licenses the licences of everything this binary contains
Local mode:
lembas -l | --local [command …]
keep everything in this project: config, sessions, memory, skills,
the library, snapshots, worktrees, caches and trust live in
.agent/local/lembas/ (git-ignored), and nothing is written outside
it. Your logins and connections.yaml are read, never written; your
config.yaml is not read (the local one is used; lembas -l config set
… writes it). A LLeMbas instance is used only for its models, chat,
speech in and out (/v1/audio) and the balance: no link, /remote or
service, no sessions or deletes sent to it, no server library,
search, fetch or embeddings, no account personalization, and no
self-update. The instance still records the usage of each request,
as any API does. The project is not trusted by -l itself: the first
start asks, and the answer is kept in the local directory.
Options for run:
-m, --model conn/model model to use (default: config.yaml \`model\`)
--mode MODE manual | edit | auto | plan
@@ -134,8 +151,33 @@ async function loginCommand(cmd: "login" | "logout", args: string[]) {
}
async function main() {
const argv = process.argv.slice(2)
// The environment as it was given, before local mode sets anything in it.
const startEnv = { ...process.env }
let argv = process.argv.slice(2)
// -l / --local: before the command, or anywhere among the TUI's options.
let local = false
while (LOCAL_FLAGS.includes(argv[0] ?? "")) {
local = true
argv = argv.slice(1)
}
if ((!argv[0] || argv[0].startsWith("-")) && argv.some((a) => LOCAL_FLAGS.includes(a))) {
local = true
argv = argv.filter((a) => !LOCAL_FLAGS.includes(a))
}
const cmd = argv[0]
if (local && (cmd === "-h" || cmd === "--help" || cmd === "help"))
return void process.stdout.write(`Local mode here keeps everything in ${localFor(process.cwd()).root}\n\n${HELP}`)
if (local && !["-v", "--version", "--licenses", "licenses", "-h", "--help", "help"].includes(cmd ?? "")) {
try {
for (const note of enterLocal(process.cwd()).notes) process.stderr.write(`${note}\n`)
} catch (e) {
if (e instanceof LocalError) fail(e.message)
throw e
}
// What local mode turns off, said in one line.
const off: Record<string, string> = { service: "lembas service", update: "lembas update", uninstall: "lembas uninstall (run it without -l)", remote: "lembas remote" }
if (cmd && off[cmd]) fail(localRefusal(off[cmd]))
}
// 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") {
@@ -157,6 +199,12 @@ async function main() {
},
})
if (!process.stdin.isTTY || !process.stdout.isTTY) fail('the TUI needs a terminal; use: lembas run "…"', 2)
// Local mode: the TUI starts again with TMPDIR inside the project, which the runtime reads
// only as it starts (the native library it unpacks goes there). The user's TMPDIR is passed on
// for the commands the agent runs.
const root = localRoot()
if (root && process.env.TMPDIR !== localTmp(root))
await (await import("./reload.ts")).restart(["-l", ...argv], { ...startEnv, TMPDIR: localTmp(root), LEMBAS_OUTER_TMPDIR: startEnv.TMPDIR ?? "" })
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")
@@ -399,15 +447,16 @@ async function main() {
if (key === "model" || key === "small_model") resolveModel(loadConfig(), String(v))
},
})
const label = (src: string) => (src === "global" && isLocal() ? "local" : src)
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})` : ""}`)
for (const e of settings.list()) console.log(`${e.def.key} = ${show(e.value) || `(${e.def.fallback ?? "unset"})`}${e.source === "default" ? "" : ` [${label(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}]`}`)
console.log(`${key} = ${show(e.value) || `(${e.def.fallback ?? "unset"})`}${e.source === "default" ? "" : ` [${label(e.source)}]`}`)
if (e.note) console.log(`⚠ ${e.note}`)
const d = describe(e.def)
if (d) console.log(d)
+58 -27
View File
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSyn
import { join } from "node:path"
import { isScalar, parse, parseDocument } from "yaml"
import { z } from "zod"
import { paths } from "./paths.ts"
import { connectionFiles, isLocal, paths } from "./paths.ts"
import { asMode, Config, Connection, ConnectionsFile, McpServer, PERSONALITIES, renamePermissionKeys, WebuiConnection, type PermissionConfig } from "./schema.ts"
import { runKeyCmd, substituteDeep } from "./substitute.ts"
import { expandWebui, refreshFailures, type WebuiMark } from "../lembas/webui.ts"
@@ -260,37 +260,38 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
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)) {
// Validate each raw file, then substitute each connection on its own: a missing variable for one
// connection must not stop the others from working. In local mode, the user's own file first
// (read, never written), then the local one, whose entries win by name.
const raw: Record<string, unknown> = {}
for (const connFile of connectionFiles()) {
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
Object.assign(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"}`,
)
}
// 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
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
}
}
@@ -388,11 +389,16 @@ export function loadConfig(opts: { projectConfigDir?: string; trusted?: boolean
config = { ...config, search: sr }
}
// Local mode: an instance serves models, chat, speech and the balance, and nothing that is kept
// or shown there — no search or fetch through it, no embeddings, no library, no personalization
// (the local keys apply) — and the whole hardline stays.
if (isLocal()) config = localOnly(config, connections, warnings)
// 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)
const personal = isLocal() ? { config } : withInstancePersonalization(config)
config = personal.config
if (config.library === undefined && defaultLibrary()) config = { ...config, library: "lembas" }
if (config.library === undefined && !isLocal() && defaultLibrary()) config = { ...config, library: "lembas" }
// An instance that speaks protocol 2 serves `<provider>/<model>` ids.
const logins = instances()
@@ -441,6 +447,31 @@ function migrateRef(file: string, key: string, old: string, now: string) {
} catch {}
}
/** What local mode takes out of a loaded config, saying so where something was asked for. */
function localOnly(config: Config, connections: Loaded["connections"], warnings: string[]): Config {
const out = { ...config }
if (out.search) {
const sr = { ...out.search }
if (sr.order?.includes("webui") || sr.webui || sr.fetch === "webui") warnings.push("local mode: web search and fetch do not go through the LLeMbas instance")
if (sr.order) sr.order = sr.order.filter((p) => p !== "webui")
delete sr.webui
if (sr.fetch === "webui") sr.fetch = "local"
out.search = sr
}
if (out.library === "lembas") warnings.push("local mode: library: lembas is ignored; this project's own library is used")
out.library = "local"
const emb = out.embedding?.split("/")[0]
if (out.embedding && emb && connections[emb]?.webui) {
warnings.push(`local mode: embedding ${out.embedding} would go through the LLeMbas instance; the library is searched by words only`)
delete out.embedding
}
if (out.hardline_disable?.length) {
warnings.push("local mode: hardline_disable is ignored; the whole hardline applies")
delete out.hardline_disable
}
return out
}
/** 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
+48 -2
View File
@@ -1,4 +1,5 @@
import { homedir } from "node:os"
import { existsSync, mkdirSync, mkdtempSync } from "node:fs"
import { homedir, tmpdir } from "node:os"
import { isAbsolute, join } from "node:path"
/** An environment variable under its LLeMbas CLI name: LEMBAS_<name>. */
@@ -17,7 +18,22 @@ function base(envVar: string, fallback: string): string {
const dir = (parent: string): string => join(parent, "lembas")
export const paths = {
/** Local mode (`lembas -l`): the one directory everything of LLeMbas CLI's lives in — config,
* data and state — inside the project (project/local.ts). Unset: the usual three. */
let local: string | undefined
export function setLocalRoot(root: string | undefined) {
local = root
}
/** The local root, in local mode. */
export const localRoot = (): string | undefined => local
export const isLocal = (): boolean => local !== undefined
/** The user's own three directories, whatever the mode. In local mode only the login is read from
* them (connections.yaml, lembas.json and the key files it names), and nothing is written there. */
export const globalPaths = {
get config() {
return dir(base("XDG_CONFIG_HOME", ".config"))
},
@@ -29,8 +45,38 @@ export const paths = {
},
}
/** Where LLeMbas CLI reads and writes its own files: the user's three directories, or the
* local root's in local mode. */
export const paths = {
get config() {
return local ? join(local, "config") : globalPaths.config
},
get data() {
return local ? join(local, "data") : globalPaths.data
},
get state() {
return local ? join(local, "state") : globalPaths.state
},
}
export function expandHome(p: string): string {
if (p === "~") return homedir()
if (p.startsWith("~/")) return join(homedir(), p.slice(2))
return p
}
/** A fresh temporary directory for LLeMbas CLI's own scratch files (speech being played or
* converted): the system's, or `state/tmp` in local mode, so nothing is left outside the project. */
export function makeTempDir(prefix: string): string {
const parent = local ? join(local, "state", "tmp") : tmpdir()
if (local) mkdirSync(parent, { recursive: true, mode: 0o700 })
return mkdtempSync(join(parent, prefix))
}
/** The connections files read, in order: in local mode the user's own (read-only) and then the
* local one; otherwise the one. Only those that exist. */
export function connectionFiles(): string[] {
const own = join(paths.config, "connections.yaml")
const all = isLocal() ? [join(globalPaths.config, "connections.yaml"), own] : [own]
return all.filter((f) => existsSync(f))
}
+3 -2
View File
@@ -1,6 +1,7 @@
// 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.
// snapshot/index.ts, written small. The shadow repo lives in ~/.local/share/lembas/snapshot/ —
// in local mode (-l) in the project's git-ignored local directory, which it leaves out of every
// snapshot — 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
+44 -18
View File
@@ -11,7 +11,7 @@
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 { globalPaths, isLocal, paths } from "../config/paths.ts"
import { awaitToken, discover, LembasError, signOut, spoken, startDevice, type DeviceStart, type Discovery, type InstanceInfo, type ServedModel, type Tls } from "./client.ts"
import { dropCache, fetchWebui, isChatModel, readCache, specFor } from "./webui.ts"
@@ -36,27 +36,47 @@ interface Index {
instances: Record<string, Instance>
}
const indexFile = () => join(paths.config, "lembas.json")
const indexFile = (dir = paths.config) => join(dir, "lembas.json")
const keyDir = () => join(paths.config, "lembas")
export const keyFile = (connection: string) => join(keyDir(), `${connection}.key`)
/** Where a login made here keeps its key. */
const ownKeyFile = (connection: string) => join(keyDir(), `${connection}.key`)
/** The key of a login: this mode's own, or — in local mode, for a login made outside it — the
* user's, read where it is. */
export const keyFile = (connection: string) => {
const own = ownKeyFile(connection)
if (!isLocal() || existsSync(own)) return own
const theirs = join(globalPaths.config, "lembas", `${connection}.key`)
return existsSync(theirs) ? theirs : own
}
/** What a later look at the instance said it speaks, kept with the login. */
/** What a later look at the instance said it speaks, kept with the login (not for a login local
* mode only reads). */
export function rememberProtocols(connection: string, protocols: number[]) {
const all = instances()
const all = ownInstances()
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> {
function readIndex(dir: string): Record<string, Instance> {
try {
const parsed = JSON.parse(readFileSync(indexFile(), "utf8")) as Index
const parsed = JSON.parse(readFileSync(indexFile(dir), "utf8")) as Index
return parsed.instances ?? {}
} catch {
return {}
}
}
/** Every login in force: in local mode the user's own (read only) and the ones made here. */
export function instances(): Record<string, Instance> {
return isLocal() ? { ...readIndex(globalPaths.config), ...readIndex(paths.config) } : readIndex(paths.config)
}
/** The logins this mode wrote, and may change: all of them, or in local mode the local ones. */
export function ownInstances(): Record<string, Instance> {
return readIndex(paths.config)
}
function saveIndex(all: Record<string, Instance>) {
mkdirSync(paths.config, { recursive: true })
writeFileSync(indexFile(), JSON.stringify({ instances: all }, null, 2) + "\n", { mode: 0o600 })
@@ -99,7 +119,7 @@ export function writeConnection(connection: string, d: Discovery, tls: Tls | und
const entry: Record<string, unknown> = {
type: "webui",
url: d.base_url,
api_key: `{file:${keyFile(connection)}}`,
api_key: `{file:${ownKeyFile(connection)}}`,
...(tls?.ca ? { tls: { ca: tls.ca } } : {}),
}
doc.setIn(["connections", connection], doc.createNode(entry))
@@ -125,7 +145,7 @@ function replaceOld(old: string, now: string, instanceDefault: string | undefine
conns.deleteIn(["connections", old])
writeYamlDoc(connectionsFile(), conns, 0o600)
}
rmSync(keyFile(old), { force: true })
rmSync(ownKeyFile(old), { force: true })
dropCache(old)
said.push(`the connection ${old} from an earlier login is now ${now}`)
if (!existsSync(configFile())) return said
@@ -176,7 +196,8 @@ export function useServices(connection: string, services: InstanceInfo["services
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) {
// Local mode searches without the instance (loadConfig would take it out again).
if (services.search && !isLocal()) {
const search = plain(["search"]) ?? {}
const order = Array.isArray(search.order) ? (search.order as string[]) : undefined
if (!order?.includes("webui")) {
@@ -232,7 +253,8 @@ function dropServices(connection: string, several: boolean): void {
* 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 []
// Local mode embeds nothing through the instance.
if (!first || isLocal()) return []
const cfg = readYamlDoc(configFile())
const now = cfg.get("embedding")
if (typeof now === "string" && now.trim()) return []
@@ -375,17 +397,19 @@ export async function login(address: string, o: LoginOptions): Promise<LoginResu
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)
writeFileSync(ownKeyFile(connection), granted.access_token + "\n", { mode: 0o600 })
chmodSync(ownKeyFile(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()
// Only logins this mode made: in local mode, the user's own login to the same instance is not
// signed out or replaced — it is not this project's.
const known = ownInstances()
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)
if (existsSync(ownKeyFile(name))) await signOut(old.base_url, readFileSync(ownKeyFile(name), "utf8").trim(), old.ca ? { ca: old.ca } : undefined)
changes.push(...replaceOld(name, connection, instanceDefault))
delete known[name]
}
@@ -454,7 +478,9 @@ export interface LogoutResult {
}
export async function logout(connection: string): Promise<LogoutResult> {
const all = instances()
const all = ownInstances()
if (!all[connection] && instances()[connection])
throw new LembasError(`${connection} was signed in outside local mode, and local mode only reads that sign-in — lembas logout without -l signs it out`)
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
@@ -468,9 +494,9 @@ export async function logout(connection: string): Promise<LogoutResult> {
ours = Boolean(m && findRef(loaded.connections, loaded.refs, m)?.connection === connection)
} catch {}
let revoked = false
if (existsSync(keyFile(connection))) {
if (existsSync(ownKeyFile(connection))) {
revoked = await signOut(instance.base_url, tokenOf(connection), instance.ca ? { ca: instance.ca } : undefined)
rmSync(keyFile(connection), { force: true })
rmSync(ownKeyFile(connection), { force: true })
}
const several = webuiCount() > 1
const doc = readYamlDoc(connectionsFile())
+2
View File
@@ -10,6 +10,7 @@
// 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 { isLocal } from "../config/paths.ts"
import type { Config } from "../config/schema.ts"
import { PERSONALITIES } from "../config/schema.ts"
import { LembasError, savePersonalization, usage, type Personalization, type Usage } from "./client.ts"
@@ -59,6 +60,7 @@ export function withInstancePersonalization(config: Config): { config: Config; c
* 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> {
if (isLocal()) throw new LembasError("local mode (-l) keeps settings in the project, not in the account")
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 })
+10 -11
View File
@@ -14,10 +14,10 @@
// 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 { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { parse } from "yaml"
import { paths } from "../config/paths.ts"
import { connectionFiles, isLocal, 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"
@@ -170,7 +170,8 @@ export async function fetchWebui(name: string, url: string, token: string, tls:
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),
// Not in local mode: the account's settings are not taken into the project.
isLocal() ? undefined : 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)
@@ -192,14 +193,12 @@ export interface Refreshed {
* 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 []
}
// In local mode the user's own connections too, read only; the local file's entries win.
const raw: Record<string, unknown> = {}
for (const file of connectionFiles())
try {
Object.assign(raw, ((parse(readFileSync(file, "utf8")) ?? {}) as { connections?: Record<string, unknown> }).connections ?? {})
} catch {}
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> => {
+9 -3
View File
@@ -3,6 +3,7 @@
import { lstatSync, readlinkSync, realpathSync } from "node:fs"
import { homedir } from "node:os"
import { basename, dirname, join, relative, resolve } from "node:path"
import { localRoot } from "../config/paths.ts"
import type { Action, Mode, PermissionConfig } from "../config/schema.ts"
import { prefix } from "./arity.ts"
import { splitCommand, words } from "./bash.ts"
@@ -222,6 +223,11 @@ function evaluateRules(req: PermissionRequest, ctx: Context): Decision {
}
// Where each path really is: a symlink is judged by what it points to.
const root = realPath(ctx.root)
// Local mode's own files are inside the project but not of it: judged as ~/.config is
// otherwise, as outside — save its worktrees, where a session works.
const own = localRoot()
const local = own === undefined ? undefined : realPath(own)
const inRoot = (p: string) => inside(p, root) && !(local && inside(p, local) && !inside(p, join(local, "data", "worktrees")))
const real = (req.paths ?? []).map(realPath)
if (req.class === "write") {
for (const [i, p] of real.entries()) {
@@ -259,12 +265,12 @@ function evaluateRules(req: PermissionRequest, ctx: Context): Decision {
// 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))
if (inRoot(p)) 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))
for (const p of real) if (inRoot(p)) ruled = strictest(ruled, lookup("read", relative(root, p) || ".", ctx.rules))
const external = [...real, ...named].filter((p) => !inside(p, root))
const external = [...real, ...named].filter((p) => !inRoot(p))
let externalAction: Action = "allow"
for (const p of external) externalAction = strictest(externalAction, lookup("external_directory", p, ctx.rules))
+16 -7
View File
@@ -2,8 +2,8 @@
// 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 { basename, join, posix, resolve } from "node:path"
import { globalPaths, localRoot, paths } from "../config/paths.ts"
import { splitCommand, words } from "./bash.ts"
import SPEC from "../../harness/permission/hardline.json"
@@ -162,13 +162,22 @@ export function substitutions(line: string): string[] {
return out
}
/** Paths no tool may write, whatever the mode (the spec's, plus LLeMbas CLI's own connections.yaml). */
/** Paths no tool may write, whatever the mode (the spec's, plus LLeMbas CLI's own connections.yaml).
* `<config>/` is guarded in the user's config directory and, in local mode, in the local one too. */
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,
])
const configs = [...new Set([paths.config, globalPaths.config])]
const guarded: [string, string][] = SPEC.protected_paths.flatMap((g) =>
g.path.startsWith("<config>/")
? configs.map((c): [string, string] => [resolve(c, g.path.slice("<config>/".length)), g.label ?? g.path])
: [[g.path.startsWith("~/") ? resolve(home, g.path.slice(2)) : g.path, g.label ?? g.path] as [string, string]],
)
for (const [p, label] of guarded) if (abs === p || abs.startsWith(p + "/")) return label
// Local mode keeps its config, keys, trust and sessions inside the project, where edit mode would
// otherwise let a tool rewrite them: no tool writes there — the program does, as it does in
// ~/.config. Except the worktrees, which are where a session may work.
const root = localRoot()
const inside = (p: string) => abs === p || abs.startsWith(p + "/")
if (root && inside(root) && !inside(join(root, "data", "worktrees"))) return "local mode's own files"
return undefined
}
+80
View File
@@ -0,0 +1,80 @@
// Local mode: `lembas -l` / `lembas --local`. Everything LLeMbas CLI keeps — config, sessions,
// memory, skills, the library, snapshots, worktrees, caches, trust, history — lives in the project,
// under its per-user directory (`.agent/local/lembas/`, git-ignored), and nothing is written
// anywhere else.
//
// What it reads from outside: only the login — the user's connections.yaml, lembas.json and the key
// files they name — read-only, so the connections and sign-ins work without being copied. The
// user's config.yaml is not read; the local one is this mode's global config. A LLeMbas instance
// is used for models, chat, speech in and out, and the balance, and for nothing that is kept there
// or shown there: no link, no shared sessions, no deletes sent, no server library, no search or
// fetch through it, no embeddings through it, no account personalization.
import { chmodSync, existsSync, mkdirSync } from "node:fs"
import { join, relative } from "node:path"
import { isLocal, localRoot, setLocalRoot } from "../config/paths.ts"
import { ignoreLocal } from "./init.ts"
import { findProject, type Project } from "./root.ts"
export class LocalError extends Error {}
/** The flags that turn it on. */
export const LOCAL_FLAGS = ["-l", "--local"]
/** What `-l` would use for `cwd`: the project's root (the nearest directory of ours, else the git
* root, else `cwd` itself) and, under its per-user directory, the local root. */
export function localFor(cwd: string): { project: Project; root: string } {
const project = findProject(cwd)
return { project, root: join(project.dir, "local", "lembas") }
}
/** Files of the local root the repository carries: settings, keys or sessions that came with a
* clone. Local mode refuses them — they would be somebody else's config passed off as yours. */
function tracked(project: Project, root: string): string[] {
if (!project.gitRoot) return []
const r = Bun.spawnSync(["git", "-C", project.gitRoot, "ls-files", "--", relative(project.gitRoot, root)], { stdout: "pipe", stderr: "pipe" })
if (r.exitCode !== 0) return []
return r.stdout.toString().split("\n").filter(Boolean)
}
/** Turn local mode on for `cwd`: make the local root (mode 700), keep it out of git, and root
* config, data and state there for the rest of this process. Returns what was done, in words. */
export function enterLocal(cwd: string): { project: Project; root: string; notes: string[] } {
const { project, root } = localFor(cwd)
const carried = tracked(project, root)
if (carried.length)
throw new LocalError(
`${relative(project.root, root)} is in the repository (${carried.slice(0, 3).join(", ")}${carried.length > 3 ? ", …" : ""}): local mode keeps your own settings and keys there, and will not use files that came with a clone — remove them from git first`,
)
const notes: string[] = []
const fresh = !existsSync(root)
for (const sub of ["config", "data", "state"]) mkdirSync(join(root, sub), { recursive: true, mode: 0o700 })
try {
chmodSync(root, 0o700)
} catch {}
if (fresh) notes.push(`local mode: everything is kept in ${relative(project.root, root) || "."}`)
if (ignoreLocal(project)) notes.push(`added the per-user ${relative(project.root, join(project.dir, "local"))}/ to .gitignore`)
setLocalRoot(root)
mkdirSync(localTmp(root), { recursive: true, mode: 0o700 })
// The TUI library's own cache (its tree-sitter parsers, under XDG_DATA_HOME/opentui), set
// before it is loaded. The commands the agent runs keep the environment they were started with:
// tool/bash.ts takes it before this runs.
process.env.XDG_DATA_HOME = join(root, "data")
return { project, root, notes }
}
/** Local mode's temporary directory. The TUI runs with TMPDIR set to it from its very start — the
* compiled binary unpacks its native library there, and reads TMPDIR only once, as it starts
* (cli.ts starts it again with it set). */
export function localTmp(root: string): string {
return join(root, "state", "tmp")
}
/** The one-line refusal for what local mode turns off. */
export function localRefusal(what: string): string {
return `${what} is off in local mode (-l): nothing is shared with or kept on a server, and nothing is written outside ${localRoot() ?? "the project"}`
}
/** Refuse when in local mode: the refusal, or undefined. */
export function refusedLocally(what: string): string | undefined {
return isLocal() ? localRefusal(what) : undefined
}
+14 -4
View File
@@ -2,7 +2,8 @@
// 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"
import { resolve } from "node:path"
import { env, localRoot } from "../config/paths.ts"
type Assistant = Extract<Message, { role: "assistant" }>
@@ -71,12 +72,21 @@ export function statusOf(err: any): number | undefined {
return undefined
}
/** LEMBAS_RECORD, where it may be written: in local mode only inside the project's local root. */
function recordDir(): string | undefined {
const dir = env("RECORD")
const root = localRoot()
if (!dir || !root) return dir
const abs = resolve(dir)
return abs === root || abs.startsWith(`${root}/`) ? abs : undefined
}
export function recordName(ref: string) {
return `${env("RECORD")}/${new Date().toISOString().replace(/[:.]/g, "-")}-${ref.replace(/[^\w.-]+/g, "_")}`
return `${recordDir()}/${new Date().toISOString().replace(/[:.]/g, "-")}-${ref.replace(/[^\w.-]+/g, "_")}`
}
export function recordFailure(ref: string, request: unknown, e: unknown) {
if (!env("RECORD")) return
if (!recordDir()) return
const name = recordName(ref)
void Bun.write(`${name}.request.json`, JSON.stringify(request, null, 2))
const err = e as ProviderError
@@ -85,7 +95,7 @@ export function recordFailure(ref: string, request: unknown, e: unknown) {
/** 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")
const dir = recordDir()
if (!dir) return body
const [a, b] = body.tee()
const name = recordName(ref)
+18 -8
View File
@@ -10,11 +10,14 @@ export function selfCommand(args: string[]): string[] {
return compiled ? [process.execPath, ...args] : [process.execPath, Bun.main, ...args]
}
function execv(argv: string[]): Error {
/** execv, or execve with `env` when given (local mode's start: TMPDIR must be set before the
* runtime reads it). */
function execv(argv: string[], env?: Record<string, string | undefined>): 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 },
execve: { args: [FFIType.ptr, 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
@@ -35,10 +38,17 @@ function execv(argv: string[]): Error {
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))
const table = (items: string[]) => {
const strings = items.map((a) => Buffer.from(`${a}\0`))
const t = new BigUint64Array(strings.length + 1)
strings.forEach((s, i) => (t[i] = BigInt(ptr(s))))
return { strings, t }
}
const args = table(argv)
if (env) {
const vars = table(Object.entries(env).flatMap(([k, v]) => (v === undefined ? [] : [`${k}=${v}`])))
libc.symbols.execve(ptr(args.strings[0]!), ptr(args.t), ptr(vars.t))
} else libc.symbols.execv(ptr(args.strings[0]!), ptr(args.t))
// execv returns only when it failed.
return new Error("execv failed")
} catch (e) {
@@ -47,9 +57,9 @@ function execv(argv: string[]): 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> {
export async function restart(args: string[], env?: Record<string, string | undefined>): Promise<never> {
const argv = selfCommand(args)
execv(argv)
const child = Bun.spawn(argv, { stdio: ["inherit", "inherit", "inherit"] })
execv(argv, env)
const child = Bun.spawn(argv, { stdio: ["inherit", "inherit", "inherit"], ...(env ? { env } : {}) })
process.exit(await child.exited)
}
+2 -1
View File
@@ -5,6 +5,7 @@ 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"
import { isLocal } from "./config/paths.ts"
export interface SettingsHost {
/** The project's config.yaml, when the project is trusted (else project scope is refused). */
@@ -87,7 +88,7 @@ export class Settings {
}
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"
const where = scope === "session" ? "for this session" : elsewhere ? "for your account" : scope === "global" ? (isLocal() ? "in local mode's config" : "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}`
+8 -1
View File
@@ -14,8 +14,15 @@ const GIT_SAFE: [string, string][] = [
["core.pager", "cat"],
["safe.bareRepository", "explicit"],
]
// Local mode starts the TUI with TMPDIR inside the project, for this program's own files; the
// commands keep the TMPDIR they had (LEMBAS_OUTER_TMPDIR, empty when there was none).
const { LEMBAS_OUTER_TMPDIR: outerTmp, ...startEnv } = process.env
if (outerTmp !== undefined) {
if (outerTmp) startEnv.TMPDIR = outerTmp
else delete startEnv.TMPDIR
}
const ENV = {
...process.env,
...startEnv,
// A command can tell it runs under the agent.
LEMBAS: "1",
GIT_PAGER: "cat",
+2 -1
View File
@@ -3,6 +3,7 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { jobNote, onJobExit } from "../tool/jobs.ts"
import { stricterMode, type App as Runtime } from "../app.ts"
import { isLocal } from "../config/paths.ts"
import type { Effort, Mode } from "../config/schema.ts"
import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
import { Dialog, type DialogSpec } from "./components/dialog.tsx"
@@ -178,7 +179,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
if (!p.onReload) return view.info("/reload is not available here", "warn")
// Back on this session, with what it was using; everything else comes from the files again.
const e = app.engine
const args = [...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort ?? "off"]
const args = [...(isLocal() ? ["-l"] : []), ...(e.sessionId ? ["-s", e.sessionId] : []), "-m", e.model.ref, "--mode", e.mode, "-e", e.effort ?? "off"]
void app
.close()
.catch(() => {})
+4 -1
View File
@@ -1,5 +1,6 @@
import { basename } from "node:path"
import { Show } from "solid-js"
import { isLocal } from "../../config/paths.ts"
import { useTui } from "../context.ts"
const MODE_LABEL = { manual: "manual", edit: "edit", auto: "AUTO", plan: "plan" } as const
@@ -41,7 +42,9 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
<span style={{ fg: c().statusText }}>{p.credits?.() ? `${p.credits()} · ` : ""}</span>
<span style={{ fg: ctx().color }}>{ctx().text}</span>
<span style={{ fg: c().statusDim }}> · {basename(app.project.root)}{p.branch ? `@${p.branch}` : ""}</span>
<span style={{ fg: c().statusDim }}> · </span>
<span style={{ fg: c().info }}>{isLocal() ? "local " : ""}</span>
<span style={{ fg: c().statusDim }}>{basename(app.project.root)}{p.branch ? `@${p.branch}` : ""}</span>
</text>
}
>
+3 -1
View File
@@ -5,6 +5,7 @@ import { createApp, type AppOptions, type App } from "../app.ts"
import { ConfigError, loadConfig } from "../config/load.ts"
import { ModelError } from "../provider/index.ts"
import { autoUpdate } from "../update/index.ts"
import { isLocal } from "../config/paths.ts"
import { Setup } from "./components/setup.tsx"
import { Bus } from "../bus/index.ts"
import { findProject, trustOf } from "../project/root.ts"
@@ -78,8 +79,9 @@ export function Boot(p: { options: TuiOptions; onQuit: () => void; onReload?: (a
setApp(a)
p.onApp?.(a)
// An update, looked for once the session is up, and only said when there is something to say.
// Not in local mode: an update writes outside the project.
const update = a.loaded.config.update
void autoUpdate({ config: update }).then((r) => {
if (!isLocal()) void autoUpdate({ config: update }).then((r) => {
if (r.kind === "installed") {
view.info(`Updated to ${r.version} — /reload to restart in the latest ${update?.channel ?? "stable"}`)
bus.emit({ type: "flash", text: `updated to ${r.version} · /reload` })
+3 -3
View File
@@ -3,8 +3,8 @@
// it comes (the level meter, and stopping on silence, as Hermes Agent's voice mode does:
// tools/voice_mode.py, MIT, © Nous Research) and the WAV file is written here.
import { spawn, type ChildProcess } from "node:child_process"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { rmSync, writeFileSync } from "node:fs"
import { makeTempDir } from "../config/paths.ts"
import { join } from "node:path"
import type { Voice } from "../config/schema.ts"
@@ -270,7 +270,7 @@ export class Recorder {
/** Play a WAV; `stop()` cuts it off. */
export function play(command: string[], audio: Uint8Array): { done: Promise<void>; stop(): void } {
const dir = mkdtempSync(join(tmpdir(), "lembas-say-"))
const dir = makeTempDir("lembas-say-")
tempDirs.add(dir)
const clean = () => {
tempDirs.delete(dir)
+3 -4
View File
@@ -1,11 +1,10 @@
// Speech to text and text to speech, over the providers voice.stt / voice.tts name. `webui` is a
// LLeMbas instance's own (OpenAI's shapes at <url>/v1, filled in by the loader); a half with a
// `fallback` uses it when its provider cannot be reached.
import { mkdtempSync, readFileSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { readFileSync, rmSync } from "node:fs"
import { join } from "node:path"
import type { Voice } from "../config/schema.ts"
import { expandHome } from "../config/paths.ts"
import { expandHome, makeTempDir } from "../config/paths.ts"
import { runKeyCmd } from "../config/substitute.ts"
import { joinUrl, request, tlsFor } from "../provider/http.ts"
@@ -83,7 +82,7 @@ async function synthesizeWith(t: Tts, text: string, signal?: AbortSignal): Promi
const provider = t.provider ?? "openai"
if (provider === "piper-cli") {
if (!t.model_path) throw new Error("voice.tts.model_path (a piper .onnx voice) is not set")
const dir = mkdtempSync(join(tmpdir(), "lembas-piper-"))
const dir = makeTempDir("lembas-piper-")
const out = join(dir, "say.wav")
try {
const args = [t.command ?? "piper", "-m", expandHome(t.model_path), "-f", out, ...(t.speed && t.speed !== 1 ? ["--length-scale", String(1 / t.speed)] : [])]