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

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

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

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
// Persistent memory, after Hermes Agent (tools/memory_tool.py, tools/memory_tool_store.py, MIT,
// © Nous Research): two small files in ~/.config/lembas/memory/, MEMORY.md (the agent's notes)
// and USER.md (who the user is), entries separated by a line holding only "§". Both go into the
// system prompt as a snapshot taken when a session starts; writes during the session reach the
// file but not the prompt, so the prompt stays the same (and cacheable) all session.
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { paths } from "../config/paths.ts"
import { threatMessage } from "./threats.ts"
export type Target = "memory" | "user"
export const DELIMITER = "\n§\n"
export const DEFAULT_LIMITS: Record<Target, number> = { memory: 2200, user: 1375 }
/** One fact per entry, and a short one (LLeMbas's MAX_MEMORY_CHARS): a long entry is several facts,
* or a note that belongs in a skill or the project's records. */
export const MAX_ENTRY = 400
const FILES: Record<Target, string> = { memory: "MEMORY.md", user: "USER.md" }
const HEADERS: Record<Target, string> = { memory: "MEMORY (your personal notes)", user: "USER PROFILE (who the user is)" }
export interface Op {
action: "add" | "replace" | "remove"
content?: string
old_text?: string
}
export interface Outcome {
ok: boolean
message: string
/** On a failure the model can fix by consolidating: what is there now. */
entries?: string[]
usage: string
}
export const memoryDir = () => join(paths.config, "memory")
export function parseEntries(raw: string): string[] {
const seen = new Set<string>()
return raw
.replace(/^/, "")
.replace(/\r\n/g, "\n")
.split(/\n[ \t]*§[ \t]*\n/)
.map((e) => e.trim())
.filter((e) => e && e !== "§" && !seen.has(e) && seen.add(e))
}
/** An entry `old` identifies: an exact match first, else the one entry containing it. */
export function findEntry(entries: string[], old: string): { index?: number; ambiguous?: boolean } {
const exact = entries.findIndex((e) => e === old)
if (exact >= 0) return { index: exact }
const hits = entries.map((e, i) => (e.includes(old) ? i : -1)).filter((i) => i >= 0)
if (hits.length > 1) return { ambiguous: true }
return hits.length ? { index: hits[0] } : {}
}
export class MemoryStore {
constructor(
private limits: Record<Target, number> = DEFAULT_LIMITS,
private dir = memoryDir(),
/** Headings in the system prompt, where they are not the global files' (a project's memory). */
private headers: Partial<Record<Target, string>> = {},
) {}
file(t: Target) {
return join(this.dir, FILES[t])
}
entries(t: Target): string[] {
const f = this.file(t)
return existsSync(f) ? parseEntries(readFileSync(f, "utf8")) : []
}
private size = (entries: string[]) => entries.join(DELIMITER).length
usage(t: Target, entries = this.entries(t)): string {
const n = this.size(entries)
const limit = this.limits[t]
return `${limit > 0 ? Math.min(100, Math.floor((n / limit) * 100)) : 0}% — ${n.toLocaleString("en")}/${limit.toLocaleString("en")} chars`
}
private save(t: Target, entries: string[]) {
const f = this.file(t)
const tmp = `${f}.${process.pid}.tmp`
writeFileSync(tmp, entries.length ? entries.join(DELIMITER) + "\n" : "")
renameSync(tmp, f)
}
/** Two sessions share these files: a change is read, made and written under a lock file, so one
* cannot write over the other's. A lock older than 10 s is somebody who died holding it. */
private locked<T>(t: Target, fn: () => T): T {
mkdirSync(this.dir, { recursive: true })
const lock = `${this.file(t)}.lock`
const until = Date.now() + 5000
let fd: number | undefined
while (fd === undefined) {
try {
fd = openSync(lock, "wx")
} catch {
try {
if (Date.now() - statSync(lock).mtimeMs > 10_000) rmSync(lock, { force: true })
} catch {}
if (Date.now() > until) break
Bun.sleepSync(20)
}
}
try {
return fn()
} finally {
if (fd !== undefined) {
closeSync(fd)
rmSync(lock, { force: true })
}
}
}
/** Apply operations all together; the size limit is checked on the result only, so one call can
* make room and add. Nothing is written unless every operation succeeds. */
apply(t: Target, ops: Op[]): Outcome {
return this.locked(t, () => this.applyNow(t, ops))
}
private applyNow(t: Target, ops: Op[]): Outcome {
const before = this.entries(t)
const work = [...before]
const fail = (message: string, show = false): Outcome => ({ ok: false, message: ops.length > 1 ? `${message} Nothing was changed (the operations go together or not at all).` : message, usage: this.usage(t, before), ...(show ? { entries: before } : {}) })
for (const [i, op] of ops.entries()) {
const at = ops.length > 1 ? `operation ${i + 1}: ` : ""
const content = op.content?.trim() ?? ""
if (op.action !== "remove") {
if (!content) return fail(`${at}${op.action} needs content${op.action === "replace" ? " — the complete new entry" : ""}.`)
const threat = threatMessage(content, "The entry")
if (threat) return fail(`${at}${threat}`)
if (/(^|\n)[ \t]*§[ \t]*(\n|$)/.test(content)) return fail(`${at}a line holding only "§" separates entries; it cannot be inside one.`)
if (content.length > MAX_ENTRY) return fail(`${at}an entry is one fact, under ${MAX_ENTRY} characters; this one is ${content.length}. Split it into facts, or keep only what will still matter.`)
}
if (op.action === "add") {
if (!work.includes(content)) work.push(content)
continue
}
if (!op.old_text?.trim())
return fail(`${at}${op.action} needs old_text: a short unique part of the entry to ${op.action}.${op.action === "replace" ? " content is the COMPLETE new entry; the whole matched entry is overwritten." : ""}`, true)
const hit = findEntry(work, op.old_text.trim())
if (hit.ambiguous) return fail(`${at}"${op.old_text}" matches more than one entry — use a longer, unique part.`, true)
if (hit.index === undefined) return fail(`${at}no entry contains "${op.old_text}".`, true)
if (op.action === "remove") work.splice(hit.index, 1)
else work[hit.index] = content
}
const limit = this.limits[t]
// Over the limit already (it was lowered, or the file edited)? Anything that does not grow it
// still goes through, so "forget X" always works.
if (this.size(work) > limit && this.size(work) > this.size(before))
return fail(
`${t === "user" ? "The user profile" : "Memory"} would be ${this.size(work).toLocaleString("en")}/${limit.toLocaleString("en")} chars. Consolidate in the same call: replace overlapping entries with one shorter entry, or remove stale ones, together with the add.`,
true,
)
const unique = [...new Set(work)]
if (unique.join("\u0000") !== before.join("\u0000")) this.save(t, unique)
return { ok: true, message: ops.length > 1 ? `Applied ${ops.length} operations.` : ops[0]!.action === "add" && before.includes(ops[0]!.content!.trim()) ? "That entry already exists; nothing added." : `Entry ${ops[0]!.action === "add" ? "added" : ops[0]!.action === "replace" ? "replaced" : "removed"}.`, usage: this.usage(t, unique) }
}
/** The system-prompt block for one target, or "" when it is empty. */
block(t: Target): string {
const entries = this.entries(t)
if (!entries.length) return ""
const bar = "═".repeat(46)
return `${bar}\n${this.headers[t] ?? HEADERS[t]} [${this.usage(t, entries)}]\n${bar}\n${entries.join(DELIMITER)}`
}
/** Both blocks, as they stand now: taken once per session. */
snapshot(): string {
return [this.block("user"), this.block("memory")].filter(Boolean).join("\n\n")
}
}
+82
View File
@@ -0,0 +1,82 @@
// Prompt-injection and exfiltration patterns for text that ends up in the system prompt: memory
// entries and skills. Ported from Hermes Agent (tools/threat_patterns.py, MIT, © Nous Research).
// Patterns anchor on attack vocabulary, not bossy English: "you must" is normal in an AGENTS.md.
const F = String.raw`(?:\w+\s+){0,8}` // bounded filler between key words
const SECRET_VAR = String.raw`\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)S?\b`
const MODIFY = String.raw`(update|modify|edit|write|change|append|add\s+to)\s+[^\n]{0,2048}`
/** "all": everywhere. "context": text not written by the user (files, tool results). "strict":
* writes the user can resolve (memory, skills). Inclusion is cumulative: strict checks all. */
export type Scope = "all" | "context" | "strict"
const PATTERNS: [string, string, Scope][] = [
[String.raw`ignore\s+${F}(previous|all|above|prior)\s+${F}instructions`, "prompt_injection", "all"],
[String.raw`system\s+prompt\s+override`, "sys_prompt_override", "all"],
[String.raw`disregard\s+${F}(your|all|any)\s+${F}(instructions|rules|guidelines)`, "disregard_rules", "all"],
[String.raw`act\s+as\s+(if|though)\s+${F}you\s+${F}(have\s+no|don't\s+have)\s+${F}(restrictions|limits|rules)`, "bypass_restrictions", "all"],
[String.raw`<!--[^>]{0,512}(?:ignore|override|system|secret|hidden)[^>]{0,512}-->`, "html_comment_injection", "all"],
[String.raw`<\s*div\s+style\s*=\s*["'][^>]{0,2048}display\s*:\s*none`, "hidden_div", "all"],
[String.raw`translate\s+[^\n]{0,512}\s+into\s+\w+(?:[\s-]+\w+){0,2}\s+and\s+(execute|run|eval)\b`, "translate_execute", "all"],
[String.raw`do\s+not\s+${F}tell\s+${F}the\s+user`, "deception_hide", "all"],
[String.raw`you\s+are\s+${F}now\s+(?:a|an|the)\s+`, "role_hijack", "context"],
[String.raw`pretend\s+${F}(you\s+are|to\s+be)\s+`, "role_pretend", "context"],
[String.raw`output\s+${F}(system|initial)\s+prompt`, "leak_system_prompt", "context"],
[String.raw`(respond|answer|reply)\s+without\s+${F}(restrictions|limitations|filters|safety)`, "remove_filters", "context"],
[String.raw`you\s+have\s+been\s+${F}(updated|upgraded|patched)\s+to`, "fake_update", "context"],
[String.raw`\bname\s+yourself\s+\w+`, "identity_override", "context"],
[String.raw`register\s+(as\s+)?a?\s*node`, "c2_node_registration", "context"],
[String.raw`(heartbeat|beacon|check[\s\-]?in)\s+(to|with)\s+`, "c2_heartbeat", "context"],
[String.raw`pull\s+(down\s+)?(?:new\s+)?task(?:ing|s)?\b`, "c2_task_pull", "context"],
[String.raw`connect\s+to\s+the\s+network\b`, "c2_network_connect", "context"],
[String.raw`you\s+must\s+(?:\w+\s+){0,3}(register|connect|report|beacon)\b`, "forced_action", "context"],
[String.raw`only\s+use\s+one[\s\-]?liners?\b`, "anti_forensic_oneliner", "context"],
[String.raw`never\s+${F}(?:create|write)\s+${F}(?:script|file)\s+${F}disk`, "anti_forensic_disk", "context"],
[String.raw`unset\s+\w*(?:CLAUDE|CODEX|HERMES|LEMBAS|AGENT|OPENAI|ANTHROPIC)\w*`, "env_var_unset_agent", "context"],
[String.raw`\b(?:cobalt\s*strike|sliver|havoc|mythic|metasploit|brainworm)\b`, "known_c2_framework", "context"],
[String.raw`\bc2\s+(?:server|channel|infrastructure|beacon)\b`, "c2_explicit", "context"],
[String.raw`\bcommand\s+and\s+control\b`, "c2_explicit_long", "context"],
[String.raw`curl\s+[^\n]{0,2048}${SECRET_VAR}`, "exfil_curl", "all"],
[String.raw`wget\s+[^\n]{0,2048}${SECRET_VAR}`, "exfil_wget", "all"],
[String.raw`cat\s+[^\n]{0,2048}(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)`, "read_secrets", "all"],
[String.raw`(send|post|upload|transmit)\s+[^\n]{0,2048}\s+(to|at)\s+https?://`, "send_to_url", "strict"],
[String.raw`(include|output|print|share)\s+${F}(conversation|chat\s+history|previous\s+messages|full\s+context|entire\s+context)`, "context_exfil", "strict"],
[String.raw`authorized_keys`, "ssh_backdoor", "strict"],
[String.raw`(?:\b(?:echo|cat|cp|mv|dd|tee|install|printf|rsync|scp|ln|append|add|write|sed|chmod|chown|truncate|rm|touch|curl|wget|git)\b|\bopen\s*\(|>>?)[^\n]{0,512}(?:\$HOME/\.ssh|~/\.ssh)`, "ssh_access", "strict"],
[String.raw`${MODIFY}(?:AGENTS\.md|CLAUDE\.md|\.cursorrules|\.clinerules)`, "agent_config_mod", "strict"],
[String.raw`${MODIFY}(?:lembas/)?(connections\.yaml|config\.yaml|SOUL\.md)`, "lembas_config_mod", "strict"],
]
const INCLUDES: Record<Scope, Scope[]> = { all: ["all"], context: ["all", "context"], strict: ["all", "context", "strict"] }
// Python's \w (what Hermes wrote these for) is Unicode; JavaScript's is ASCII, so "ignore všetky
// previous instructions" slipped through. A letter is any letter.
const COMPILED = PATTERNS.map(([re, id, scope]) => ({ re: new RegExp(re.replaceAll(String.raw`\w`, String.raw`[\p{L}\p{N}_]`), "iu"), id, scope }))
// A value that names an environment variable (MY_APP_PASSWORD) says where a secret lives; it is
// not one. Hermes does this with a case-sensitive inline group, which JavaScript lacks.
const SECRET = /(?:api[_-]?key|token|secret|password)\s*[=:]\s*["']([A-Za-z0-9+/=_-]{20,})/gi
const ENV_NAME = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/
export const INVISIBLE = new Set("​‌‍⁠⁢⁣⁤‪‫‬‭‮⁦⁧⁨⁩")
// As much as anything scanned may hold (a skill file is up to 100,000 characters).
const MAX_SCAN = 131_072
/** The ids of every pattern `text` matches in `scope`. */
export function scanThreats(text: string, scope: Scope = "context"): string[] {
if (!text) return []
text = text.slice(0, MAX_SCAN)
// Invisible characters on the raw text: NFKC can remove them.
const found = [...new Set(text)].filter((c) => INVISIBLE.has(c)).map((c) => `invisible_unicode_U+${c.codePointAt(0)!.toString(16).toUpperCase().padStart(4, "0")}`)
const norm = text.normalize("NFKC")
const scopes = INCLUDES[scope]
for (const p of COMPILED) if (scopes.includes(p.scope) && p.re.test(norm)) found.push(p.id)
if (scope === "strict") for (const m of norm.matchAll(SECRET)) if (!ENV_NAME.test(m[1]!)) found.push("hardcoded_secret")
return [...new Set(found)]
}
/** A refusal for the first threat found, or undefined. */
export function threatMessage(text: string, what = "Content"): string | undefined {
const [first] = scanThreats(text, "strict")
if (!first) return undefined
if (first.startsWith("invisible_unicode_")) return `Blocked: ${what.toLowerCase()} contains the invisible character ${first.slice(18)} (a common injection carrier).`
return `Blocked: ${what.toLowerCase()} matches the threat pattern "${first}". It goes into the system prompt of every session, so it must not carry instructions aimed at the agent or anything that leaks secrets.`
}