Only memory comes from the server's /mcp; notes, knowledge and skills stay local
Harness spec 3.0.0 (acp.md *Memory*): memory records are the server's for the device as for a chat; everything else in the library is the device's own. - `library: lembas` (the default when the login's token has the `memory` scope, or the older `library`) adds the server's /mcp with its `memory` tool alone, in place of this machine's memory tool; notes_*, knowledge_* and skill_* are always this machine's. No prompts, resources or instructions are taken from it. - The memory in the prompt is the server's: the resource lembas://memory, read as a session starts (McpManager.readResource) and kept for it — put in while nothing has been sent, so the prompt never changes under a session. - A session the client opened (session/new, or opened from the store) sends its id as `_meta.lembas.scope` on every memory call and on the resource read (App.setScope); a session of the device's own sends none. - An instance's `library` context (its knowledge bases) is no longer read: the knowledge block names this machine's bases only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+134
-93
No files matched your search
+5
-8
@@ -209,14 +209,8 @@ export function contextOf(meta: any): SessionContext | undefined {
|
||||
if (m.personalization === null) out.personalization = null
|
||||
else if (m.personalization && typeof m.personalization === "object")
|
||||
out.personalization = { personality: str(m.personalization.personality), personality_custom: str(m.personalization.personality_custom), instructions: str(m.personalization.instructions) }
|
||||
if (m.library && typeof m.library === "object")
|
||||
out.library = {
|
||||
bases: (Array.isArray(m.library.bases) ? (m.library.bases as unknown[]) : [])
|
||||
.filter((b): b is { id: unknown; name: unknown } => !!b && typeof b === "object")
|
||||
.map((b) => ({ id: str(b.id), name: str(b.name) }))
|
||||
.filter((b) => b.id || b.name)
|
||||
.map((b) => ({ id: b.id || b.name, name: b.name || b.id })),
|
||||
}
|
||||
// `library` (an instance's knowledge bases, 1.1.0) is not read: the CLI's knowledge bases are its
|
||||
// own (spec 3.0.0), and a 3.0.0 instance sends none.
|
||||
if (m.prototypes && typeof m.prototypes === "object") out.prototypes = { offered: m.prototypes.offered === true, sources: str(m.prototypes.sources) }
|
||||
return Object.keys(out).length ? out : undefined
|
||||
}
|
||||
@@ -595,6 +589,8 @@ export class AcpAgent {
|
||||
session.id = id
|
||||
session.turns = turnLog(id)
|
||||
markRemote(session.app.store, id)
|
||||
// The client's chat: its memory calls land in that chat's data group (acp.md *Memory*).
|
||||
session.app.setScope(id)
|
||||
this.takeContext(session, p)
|
||||
this.sessions.set(id, session)
|
||||
this.adopt(session)
|
||||
@@ -668,6 +664,7 @@ export class AcpAgent {
|
||||
session.app = createApp({ cwd: dir, ...(L ? { mode: L.maxMode } : {}), asker: this.asker(session), modelTitles: false, resuming: id })
|
||||
session.app.resume(id)
|
||||
markRemote(session.app.store, id)
|
||||
session.app.setScope(id)
|
||||
if (L) session.app.engine.mode = clampMode(session.app.engine.mode, L.maxMode)
|
||||
this.sessions.set(id, session)
|
||||
this.adopt(session)
|
||||
|
||||
+52
-26
@@ -83,9 +83,6 @@ export interface SessionContext {
|
||||
instructions?: string
|
||||
/** The account's personalization, in place of this device's; null: the device's own. */
|
||||
personalization?: { personality: string; personality_custom: string; instructions: string } | null
|
||||
/** The knowledge bases this session may search (named to the model, and the scope sent on
|
||||
* every library call); an empty list: the account's whole library. */
|
||||
library?: { bases: { id: string; name: string }[] }
|
||||
/** Whether the model may write a prototype, and what a page may load. */
|
||||
prototypes?: { offered: boolean; sources: string }
|
||||
}
|
||||
@@ -151,27 +148,36 @@ export interface App {
|
||||
context: SessionContext
|
||||
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
|
||||
setContext(ctx: SessionContext, from?: string): void
|
||||
/** The chat a session the client opened belongs to (its id): sent with every call to the server's
|
||||
* memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a
|
||||
* session of this device's own. The memory for the prompt is read again in it. */
|
||||
setScope(scope: string | undefined): 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"])
|
||||
/** What the LLeMbas server's /mcp stands in for (harness spec 3.0.0, acp.md *Memory*): memory
|
||||
* alone. Notes, knowledge bases and skills stay this machine's. */
|
||||
const SERVER_MEMORY = new Set(["memory"])
|
||||
/** The resource holding the server's memory entries as the prompt's memory block shows them. */
|
||||
export const MEMORY_RESOURCE = "lembas://memory"
|
||||
|
||||
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")
|
||||
const one = all.length > 1 ? [...all].sort((a, b) => b.logged_in_at.localeCompare(a.logged_in_at))[0] : all[0]
|
||||
if (!one || !existsSync(lembasKeyFile(one.connection))) {
|
||||
warnings.push(one ? "library: lembas, but the login's key file is gone (lembas login again); memory is this machine's for now" : "library: lembas, but not logged in (lembas login); memory is this machine's for now")
|
||||
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,
|
||||
// The memory tool alone; the resource is read for the prompt, not offered to the model.
|
||||
tools: { include: [...SERVER_MEMORY] },
|
||||
prompts: false,
|
||||
resources: false,
|
||||
instructions: false,
|
||||
...(one.ca ? { tls: { ca: one.ca } } : {}),
|
||||
source: "global",
|
||||
}
|
||||
@@ -261,12 +267,17 @@ export function createApp(o: AppOptions): App {
|
||||
// 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,
|
||||
// The server's memory, when it is the server's: as last read for this session (readServerMemory).
|
||||
memory: !memoryOn ? undefined : serverMemory !== undefined ? serverMemory.text : [memory.snapshot(), projectMemory?.block("memory") ?? ""].filter(Boolean).join("\n\n"),
|
||||
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),
|
||||
})
|
||||
// `library: lembas`: the server keeps the memory (acp.md *Memory*); its entries for the prompt are
|
||||
// read from it as a session starts. Until the read answers, none.
|
||||
const memoryOnServer = loaded.config.library === "lembas" && memoryOn
|
||||
let serverMemory: { text: string } | undefined = memoryOnServer ? { text: "" } : undefined
|
||||
let frozen = freeze()
|
||||
|
||||
// The library: one file for every project; an embedding model only when one is set (and usable).
|
||||
@@ -284,8 +295,10 @@ export function createApp(o: AppOptions): App {
|
||||
|
||||
// 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
|
||||
const lembasLibrary = memoryOnServer ? lembasLibraryServer(loaded.warnings) : undefined
|
||||
if (lembasLibrary) loaded.mcp[LIBRARY_SERVER] = lembasLibrary
|
||||
// Without a way to the server's memory, this machine's is used for now.
|
||||
if (memoryOnServer && !lembasLibrary) serverMemory = undefined, frozen = freeze()
|
||||
const mcp = new McpManager(loaded.mcp, {
|
||||
root: project.root,
|
||||
version: VERSION,
|
||||
@@ -317,15 +330,11 @@ export function createApp(o: AppOptions): App {
|
||||
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() },
|
||||
})
|
||||
/** Whether knowledge_search is offered (engine.offered's rule): a base with documents in scope,
|
||||
* or the instance's library standing in for this machine's. */
|
||||
const knowledgeOffered = () => Boolean(lembasLibrary) || library.bases().some((b) => b.documents > 0 && (!libraryCtx.bases || libraryCtx.bases.includes(b.name)))
|
||||
/** The library block's scope: the client's bases over the link, else this machine's `knowledge:`. */
|
||||
const libraryBlock = () => {
|
||||
if (!knowledgeOffered()) return undefined
|
||||
const names = lembasLibrary ? context.library?.bases.map((b) => b.name) : libraryCtx.bases
|
||||
return { scope: knowledgeScope(names) }
|
||||
}
|
||||
/** Whether knowledge_search is offered (engine.offered's rule): a base of this machine's with
|
||||
* documents in scope. Knowledge bases are this machine's alone (spec 3.0.0). */
|
||||
const knowledgeOffered = () => library.bases().some((b) => b.documents > 0 && (!libraryCtx.bases || libraryCtx.bases.includes(b.name)))
|
||||
/** The library block's scope: this machine's `knowledge:`. */
|
||||
const libraryBlock = () => (knowledgeOffered() ? { scope: knowledgeScope(libraryCtx.bases) } : undefined)
|
||||
/** Prototypes: offered by the client for this session, or by `prototypes: true` for a terminal's. */
|
||||
const prototypesBlock = () => (context.prototypes?.offered || (!context.prototypes && loaded.config.prototypes === true) ? { sources: context.prototypes?.sources ?? "" } : undefined)
|
||||
/** The three personal keys this session uses: the client's for it, else the config's. */
|
||||
@@ -376,7 +385,7 @@ export function createApp(o: AppOptions): App {
|
||||
// own. Only its final reply goes back; its tool lines are shown under the task line.
|
||||
const agentList = agents(trusted ? project.dir : undefined, loaded.warnings)
|
||||
const offered = BUILTIN_TOOLS.filter(
|
||||
(t) => (memoryOn || t.name !== "memory") && (settingsTool !== "off" || t.name !== "settings") && !(lembasLibrary && LOCAL_LIBRARY.has(t.name)) && !SUBAGENT_ONLY.has(t.name),
|
||||
(t) => (memoryOn || t.name !== "memory") && (settingsTool !== "off" || t.name !== "settings") && !(lembasLibrary && SERVER_MEMORY.has(t.name)) && !SUBAGENT_ONLY.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`)
|
||||
@@ -586,6 +595,20 @@ export function createApp(o: AppOptions): App {
|
||||
}
|
||||
})
|
||||
const mcpReady = mcp.start()
|
||||
/** The server's memory entries for the prompt (`lembas://memory`), read as a session starts, in
|
||||
* the session's scope: put into the prompt while nothing has been sent yet, kept for the next
|
||||
* session otherwise — the prompt does not change under a session. */
|
||||
const readServerMemory = async () => {
|
||||
if (!lembasLibrary || !serverMemory) return
|
||||
try {
|
||||
const text = await mcp.readResource(LIBRARY_SERVER, MEMORY_RESOURCE, engine.o.toolCtx.libraryScope)
|
||||
serverMemory = { text }
|
||||
if (!engine.messages.length && memoryOn) frozen = { ...frozen, memory: text }
|
||||
} catch (e) {
|
||||
bus.emit({ type: "notice", level: "warn", message: `memory: the server's entries could not be read (${(e as Error).message}); none are in this session's prompt` })
|
||||
}
|
||||
}
|
||||
void mcpReady.then(readServerMemory)
|
||||
|
||||
// small_model when it resolves; a broken one is said once and the session's model used instead.
|
||||
let smallWarned = false
|
||||
@@ -754,9 +777,10 @@ export function createApp(o: AppOptions): App {
|
||||
setContext(ctx, from) {
|
||||
context = ctx
|
||||
if (from) contextFrom = from
|
||||
// The instance library answers within the chat's scope: the session's id rides on every
|
||||
// call to it while the client has said what the session may search.
|
||||
engine.o.toolCtx.libraryScope = ctx.library ? engine.sessionId : undefined
|
||||
},
|
||||
setScope(scope) {
|
||||
engine.o.toolCtx.libraryScope = scope
|
||||
void readServerMemory()
|
||||
},
|
||||
rename(title) {
|
||||
const id = engine.sessionId
|
||||
@@ -766,6 +790,7 @@ export function createApp(o: AppOptions): App {
|
||||
},
|
||||
newSession() {
|
||||
frozen = freeze()
|
||||
void readServerMemory()
|
||||
named = "no"
|
||||
firstShown = ""
|
||||
const s = store?.createSession(project.root, engine.model.ref)
|
||||
@@ -783,6 +808,7 @@ export function createApp(o: AppOptions): App {
|
||||
if (!store) return []
|
||||
const before = engine.sessionId
|
||||
frozen = freeze()
|
||||
void readServerMemory()
|
||||
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 = ""
|
||||
|
||||
@@ -297,7 +297,7 @@ export const Config = z.strictObject({
|
||||
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."),
|
||||
prototypes: z.boolean().optional().describe("Global config only. Offer the model prototypes: a complete HTML page in a `prototype-chat` or `prototype-page` fence, saved as a file under the state directory (prototypes/<session>/) and listed by /prototype; the LLeMbas web UI renders the same fences live. Default false."),
|
||||
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)."),
|
||||
library: z.enum(["local", "lembas"]).optional().describe("Global config only. Where memory lives. lembas (the default when the login may reach it): the account's memory records on the LLeMbas server, through its /mcp — the same records its chats have. local: this machine's memory files. Notes, knowledge bases and skills are this machine's either way."),
|
||||
remote: z
|
||||
.strictObject({
|
||||
enabled: z.boolean().optional().describe("Accept work from a LLeMbas instance this machine is logged in to (lembas service). Default false."),
|
||||
|
||||
@@ -30,10 +30,11 @@ export function soleInstance(): { instance: Instance; token: string } | undefine
|
||||
}
|
||||
}
|
||||
|
||||
/** §10: the library a config with no `library:` uses — the account's, when the login may read it. */
|
||||
/** Where memory lives when config.yaml has no `library:` — the server's, when the login's token
|
||||
* may reach it (scope `memory`, or the older `library`; harness/acp.md *Memory*). */
|
||||
export function defaultLibrary(): "lembas" | undefined {
|
||||
const one = soleInstance()
|
||||
return one?.instance.scope?.split(/\s+/).includes("library") ? "lembas" : undefined
|
||||
const scopes = soleInstance()?.instance.scope?.split(/\s+/) ?? []
|
||||
return scopes.includes("memory") || scopes.includes("library") ? "lembas" : undefined
|
||||
}
|
||||
|
||||
/** The instance's personalization as last read, when it is the one that counts. */
|
||||
|
||||
+14
-5
@@ -74,8 +74,9 @@ export interface McpPromptCommand {
|
||||
|
||||
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. */
|
||||
/** The LLeMbas server's /mcp, for its memory (`library: lembas`, harness/acp.md *Memory*): its
|
||||
* `memory` tool keeps its name and stands in for this machine's; notes, knowledge and skills stay
|
||||
* this machine's (spec 3.0.0). */
|
||||
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 {
|
||||
@@ -361,6 +362,14 @@ export class McpManager {
|
||||
return s.client
|
||||
}
|
||||
|
||||
/** A resource's text, read from a server directly (not offered to the model): the LLeMbas
|
||||
* server's `lembas://memory` for the prompt. `scope` rides as on a tool call. */
|
||||
async readResource(server: string, uri: string, scope?: string): Promise<string> {
|
||||
const c = await this.clientOf(server)
|
||||
const r = await c.readResource({ uri, ...(scope ? { _meta: { lembas: { scope } } } : {}) })
|
||||
return r.contents.map((x) => ("text" in x && typeof x.text === "string" ? x.text : "")).join("\n").trim()
|
||||
}
|
||||
|
||||
/** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
|
||||
tools(): Tool[] {
|
||||
const out: Tool[] = []
|
||||
@@ -386,13 +395,13 @@ export class McpManager {
|
||||
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.
|
||||
// The library's writes are the account's memory, 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 {
|
||||
// The instance's library answers within the chat's scope (harness/acp.md, Scope).
|
||||
// The server's memory answers within the chat's scope (harness/acp.md, Scope).
|
||||
const scope = library ? ctx.libraryScope : undefined
|
||||
const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown>, ...(scope ? { _meta: { lembas: { scope } } } : {}) }, undefined, {
|
||||
signal: ctx.signal,
|
||||
|
||||
+2
-2
@@ -70,8 +70,8 @@ export interface ToolContext {
|
||||
/** Open a file in the user's viewer (the show tool): set only where a client over the link said
|
||||
* it can show files (harness/acp.md, capability `show`). Absent: no display, and show is not offered. */
|
||||
show?: (req: import("./show.ts").ShowRequest, callId?: string) => Promise<import("./show.ts").ShowReply>
|
||||
/** The scope sent on every call to the instance's library (`_meta.lembas.scope`, harness/acp.md):
|
||||
* the session's id, where the client has said what the session may search. */
|
||||
/** The scope sent on every call to the server's memory (`_meta.lembas.scope`, harness/acp.md
|
||||
* *Memory*): the id of a session the client opened; none for a session of this device's own. */
|
||||
libraryScope?: string
|
||||
/** The library: notes, and the knowledge bases this project searches (all, when
|
||||
* `bases` is undefined); `project` is the scope of this project's notes. */
|
||||
|
||||
Reference in new issue
Block a user