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:
HomerandClaude Opus 5.5 committed 2026-10-27 21:07:37 +00:00
1 parent 6d5a25cdad
commit a73adc01f0
10 files changed
+134 -93

No files matched your search

+1 -1
View File
@@ -996,7 +996,7 @@
"type": "boolean" "type": "boolean"
}, },
"library": { "library": {
"description": "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).", "description": "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.",
"type": "string", "type": "string",
"enum": [ "enum": [
"local", "local",
+5 -8
View File
@@ -209,14 +209,8 @@ export function contextOf(meta: any): SessionContext | undefined {
if (m.personalization === null) out.personalization = null if (m.personalization === null) out.personalization = null
else if (m.personalization && typeof m.personalization === "object") 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) } 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") // `library` (an instance's knowledge bases, 1.1.0) is not read: the CLI's knowledge bases are its
out.library = { // own (spec 3.0.0), and a 3.0.0 instance sends none.
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 })),
}
if (m.prototypes && typeof m.prototypes === "object") out.prototypes = { offered: m.prototypes.offered === true, sources: str(m.prototypes.sources) } 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 return Object.keys(out).length ? out : undefined
} }
@@ -595,6 +589,8 @@ export class AcpAgent {
session.id = id session.id = id
session.turns = turnLog(id) session.turns = turnLog(id)
markRemote(session.app.store, 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.takeContext(session, p)
this.sessions.set(id, session) this.sessions.set(id, session)
this.adopt(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 = createApp({ cwd: dir, ...(L ? { mode: L.maxMode } : {}), asker: this.asker(session), modelTitles: false, resuming: id })
session.app.resume(id) session.app.resume(id)
markRemote(session.app.store, id) markRemote(session.app.store, id)
session.app.setScope(id)
if (L) session.app.engine.mode = clampMode(session.app.engine.mode, L.maxMode) if (L) session.app.engine.mode = clampMode(session.app.engine.mode, L.maxMode)
this.sessions.set(id, session) this.sessions.set(id, session)
this.adopt(session) this.adopt(session)
+52 -26
View File
@@ -83,9 +83,6 @@ export interface SessionContext {
instructions?: string instructions?: string
/** The account's personalization, in place of this device's; null: the device's own. */ /** The account's personalization, in place of this device's; null: the device's own. */
personalization?: { personality: string; personality_custom: string; instructions: string } | null 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. */ /** Whether the model may write a prototype, and what a page may load. */
prototypes?: { offered: boolean; sources: string } prototypes?: { offered: boolean; sources: string }
} }
@@ -151,27 +148,36 @@ export interface App {
context: SessionContext context: SessionContext
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */ /** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
setContext(ctx: SessionContext, from?: string): void 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. */ /** What the LLeMbas server's /mcp stands in for (harness spec 3.0.0, acp.md *Memory*): memory
const LOCAL_LIBRARY = new Set(["memory", "notes_search", "note_view", "note_manage", "knowledge_search", "knowledge_get", "skills_list", "skill_view", "skill_manage"]) * 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 { function lembasLibraryServer(warnings: string[]): (McpServer & { source: "global" }) | undefined {
const all = Object.values(lembasInstances()) const all = Object.values(lembasInstances())
const keyMissing = all.length === 1 && !existsSync(lembasKeyFile(all[0]!.connection)) const one = all.length > 1 ? [...all].sort((a, b) => b.logged_in_at.localeCompare(a.logged_in_at))[0] : all[0]
if (all.length !== 1 || keyMissing) { if (!one || !existsSync(lembasKeyFile(one.connection))) {
if (keyMissing) warnings.push("library: lembas, but the login's key file is gone (lembas login again); using this machine's library") 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")
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 return undefined
} }
const one = all[0]!
return { return {
url: `${one.base_url}/mcp`, url: `${one.base_url}/mcp`,
// The token itself, read now: the config's {file:} substitution has already run by here. // The token itself, read now: the config's {file:} substitution has already run by here.
headers: { authorization: `Bearer ${readFileSync(lembasKeyFile(one.connection), "utf8").trim()}` }, headers: { authorization: `Bearer ${readFileSync(lembasKeyFile(one.connection), "utf8").trim()}` },
transport: "http", transport: "http",
oauth: false, 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 } } : {}), ...(one.ca ? { tls: { ca: one.ca } } : {}),
source: "global", 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 // the same reason as the git block: the prompt must stay byte for byte the same, or the server
// reads the whole conversation again. // reads the whole conversation again.
const freeze = () => ({ 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()), skills: skillLines(skillList()),
git: gitSummary(), git: gitSummary(),
tasks: projectDir() && !unsafeProjectFile(join(project.dir, "tasks.md"), project.root) ? boardSummary(project.dir) : undefined, tasks: projectDir() && !unsafeProjectFile(join(project.dir, "tasks.md"), project.root) ? boardSummary(project.dir) : undefined,
files: instructionFiles(o.cwd, project.root, loaded.instructions), 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() let frozen = freeze()
// The library: one file for every project; an embedding model only when one is set (and usable). // 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 // 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. // 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 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, { const mcp = new McpManager(loaded.mcp, {
root: project.root, root: project.root,
version: VERSION, version: VERSION,
@@ -317,15 +330,11 @@ export function createApp(o: AppOptions): App {
sessions: store ? { store, current: () => engine.sessionId } : undefined, 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() }, 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, /** Whether knowledge_search is offered (engine.offered's rule): a base of this machine's with
* or the instance's library standing in for this machine's. */ * documents in scope. Knowledge bases are this machine's alone (spec 3.0.0). */
const knowledgeOffered = () => Boolean(lembasLibrary) || library.bases().some((b) => b.documents > 0 && (!libraryCtx.bases || libraryCtx.bases.includes(b.name))) const knowledgeOffered = () => 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:`. */ /** The library block's scope: this machine's `knowledge:`. */
const libraryBlock = () => { const libraryBlock = () => (knowledgeOffered() ? { scope: knowledgeScope(libraryCtx.bases) } : undefined)
if (!knowledgeOffered()) return undefined
const names = lembasLibrary ? context.library?.bases.map((b) => b.name) : libraryCtx.bases
return { scope: knowledgeScope(names) }
}
/** Prototypes: offered by the client for this session, or by `prototypes: true` for a terminal's. */ /** 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) 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. */ /** 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. // 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 agentList = agents(trusted ? project.dir : undefined, loaded.warnings)
const offered = BUILTIN_TOOLS.filter( 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). // 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 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() 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. // small_model when it resolves; a broken one is said once and the session's model used instead.
let smallWarned = false let smallWarned = false
@@ -754,9 +777,10 @@ export function createApp(o: AppOptions): App {
setContext(ctx, from) { setContext(ctx, from) {
context = ctx context = ctx
if (from) contextFrom = from 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. setScope(scope) {
engine.o.toolCtx.libraryScope = ctx.library ? engine.sessionId : undefined engine.o.toolCtx.libraryScope = scope
void readServerMemory()
}, },
rename(title) { rename(title) {
const id = engine.sessionId const id = engine.sessionId
@@ -766,6 +790,7 @@ export function createApp(o: AppOptions): App {
}, },
newSession() { newSession() {
frozen = freeze() frozen = freeze()
void readServerMemory()
named = "no" named = "no"
firstShown = "" firstShown = ""
const s = store?.createSession(project.root, engine.model.ref) const s = store?.createSession(project.root, engine.model.ref)
@@ -783,6 +808,7 @@ export function createApp(o: AppOptions): App {
if (!store) return [] if (!store) return []
const before = engine.sessionId const before = engine.sessionId
frozen = freeze() frozen = freeze()
void readServerMemory()
engine.resume(id, store.context(id)) 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. // A session from before titles gets its first prompt's line now, and the model's after the next reply.
firstShown = "" firstShown = ""
+1 -1
View File
@@ -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."), 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."), 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."), 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 remote: z
.strictObject({ .strictObject({
enabled: z.boolean().optional().describe("Accept work from a LLeMbas instance this machine is logged in to (lembas service). Default false."), enabled: z.boolean().optional().describe("Accept work from a LLeMbas instance this machine is logged in to (lembas service). Default false."),
+4 -3
View File
@@ -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 { export function defaultLibrary(): "lembas" | undefined {
const one = soleInstance() const scopes = soleInstance()?.instance.scope?.split(/\s+/) ?? []
return one?.instance.scope?.split(/\s+/).includes("library") ? "lembas" : undefined return scopes.includes("memory") || scopes.includes("library") ? "lembas" : undefined
} }
/** The instance's personalization as last read, when it is the one that counts. */ /** The instance's personalization as last read, when it is the one that counts. */
+14 -5
View File
@@ -74,8 +74,9 @@ export interface McpPromptCommand {
const sanitize = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, "_") 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 /** The LLeMbas server's /mcp, for its memory (`library: lembas`, harness/acp.md *Memory*): its
* LLeMbas chat, and stand in for this machine's memory, notes, skills and knowledge tools. */ * `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" export const LIBRARY_SERVER = "lembas-library"
/** mcp__<server>__<tool>, at most 64 characters (the providers' limit); longer ones keep a hash. */ /** mcp__<server>__<tool>, at most 64 characters (the providers' limit); longer ones keep a hash. */
export function toolName(server: string, tool: string): string { export function toolName(server: string, tool: string): string {
@@ -361,6 +362,14 @@ export class McpManager {
return s.client 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. */ /** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */
tools(): Tool[] { tools(): Tool[] {
const out: 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})`, 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, schema: LOOSE,
jsonSchema: objectSchema(t.inputSchema), jsonSchema: objectSchema(t.inputSchema),
// The library's writes are the account's memory and notes, not this machine: asked like // The library's writes are the account's memory, not this machine: asked like the local
// the local memory tool (interact), never run unasked as a command would be denied. // memory tool (interact), never run unasked as a command would be denied.
permission: () => ({ permission: name, class: readOnly ? "read" : library ? "interact" : "execute", patterns: ["*"] }), permission: () => ({ permission: name, class: readOnly ? "read" : library ? "interact" : "execute", patterns: ["*"] }),
run: async (args, ctx) => { run: async (args, ctx) => {
const c = await this.clientOf(s.name) const c = await this.clientOf(s.name)
try { 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 scope = library ? ctx.libraryScope : undefined
const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown>, ...(scope ? { _meta: { lembas: { scope } } } : {}) }, undefined, { const r = await c.callTool({ name: t.name, arguments: args as Record<string, unknown>, ...(scope ? { _meta: { lembas: { scope } } } : {}) }, undefined, {
signal: ctx.signal, signal: ctx.signal,
+2 -2
View File
@@ -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 /** 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. */ * 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> 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 scope sent on every call to the server's memory (`_meta.lembas.scope`, harness/acp.md
* the session's id, where the client has said what the session may search. */ * *Memory*): the id of a session the client opened; none for a session of this device's own. */
libraryScope?: string libraryScope?: string
/** The library: notes, and the knowledge bases this project searches (all, when /** The library: notes, and the knowledge bases this project searches (all, when
* `bases` is undefined); `project` is the scope of this project's notes. */ * `bases` is undefined); `project` is the scope of this project's notes. */
+4 -3
View File
@@ -332,7 +332,7 @@ test("show (spec 3.0.0): offered only to a client that shows files; the file goe
expect(fake.requests[2].tools.map((t: any) => t.function.name)).not.toContain("show") expect(fake.requests[2].tools.map((t: any) => t.function.name)).not.toContain("show")
}) })
test("the context reaches the prompt: the instance's instructions, its personalization, the library's scope, prototypes", async () => { test("the context reaches the prompt: the instance's instructions, its personalization, prototypes; not its knowledge bases", async () => {
fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }, { chunks: [delta({ content: "three" })] }]) fake = fakeProvider([{ chunks: [delta({ content: "one" })] }, { chunks: [delta({ content: "two" })] }, { chunks: [delta({ content: "three" })] }])
config(fake.url, "personality: concise\ninstructions: local words\n") config(fake.url, "personality: concise\ninstructions: local words\n")
const root = project() const root = project()
@@ -356,8 +356,9 @@ test("the context reaches the prompt: the instance's instructions, its personali
expect(system()).toContain("prototype-chat") expect(system()).toContain("prototype-chat")
expect(system()).toContain("The page may load nothing from the network.") expect(system()).toContain("The page may load nothing from the network.")
expect(system()).toContain("It is saved as a file you can open, and the LLeMbas web UI renders it live.") expect(system()).toContain("It is saved as a file you can open, and the LLeMbas web UI renders it live.")
// The library's scope is named only where its tools are offered (none here: no local base, no // The instance's knowledge bases are not the device's (spec 3.0.0): an older client's `library`
// instance library) — nothing unfilled reaches the model either way. // names nothing to the model — nothing unfilled reaches it either.
expect(system()).not.toContain("Manuals")
expect(system()).not.toContain("{{") expect(system()).not.toContain("{{")
// A later prompt replaces the context: personalization back to the device's, nothing from the instance. // A later prompt replaces the context: personalization back to the device's, nothing from the instance.
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }], _meta: { lembas: { turnId: "T2", system_prompt: "", personalization: null, prototypes: { offered: false, sources: "" } } } }) await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "again" }], _meta: { lembas: { turnId: "T2", system_prompt: "", personalization: null, prototypes: { offered: false, sources: "" } } } })
+1 -1
View File
@@ -220,7 +220,7 @@ test("every {{variable}} in a description is declared, and none reaches a model"
else expect(system).not.toContain("The current plan") else expect(system).not.toContain("The current plan")
} }
} }
app.setContext({ instructions: "Be brief.", prototypes: { offered: true, sources: "" }, library: { bases: [{ id: "k", name: "K" }] } }, "example") app.setContext({ instructions: "Be brief.", prototypes: { offered: true, sources: "" } }, "example")
const withContext = app.engine.o.system("manual", app.engine.model, plan) const withContext = app.engine.o.system("manual", app.engine.model, plan)
expect(withContext).not.toContain("{{") expect(withContext).not.toContain("{{")
expect(withContext).toContain("## Instructions from example\n\nBe brief.") expect(withContext).toContain("## Instructions from example\n\nBe brief.")
+50 -43
View File
@@ -1,5 +1,6 @@
// library: lembas: the account's library through the instance's /mcp, in place of this // library: lembas (harness spec 3.0.0, acp.md *Memory*): the account's memory through the server's
// machine's — the tools under their own names, as a LLeMbas chat has them, with the device token. // /mcp — its `memory` tool in place of this machine's, and its entries for the prompt from the
// resource lembas://memory — with the device token. Notes, knowledge bases and skills stay local.
import { afterEach, expect, test } from "bun:test" import { afterEach, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
@@ -13,8 +14,8 @@ afterEach(() => {
for (const s of stops.splice(0)) s() for (const s of stops.splice(0)) s()
}) })
function fakeLembasMcp() { function fakeLembasMcp(memory = "MEMORY (the account's)\nPrefers tabs.") {
const calls: { auth: string; name?: string; args?: unknown; meta?: unknown }[] = [] const calls: { auth: string; name?: string; args?: unknown; meta?: unknown; uri?: string }[] = []
const server = Bun.serve({ const server = Bun.serve({
port: 0, port: 0,
async fetch(req) { async fetch(req) {
@@ -26,11 +27,16 @@ function fakeLembasMcp() {
if (auth !== "Bearer lmb_lib") return new Response("no", { status: 401 }) if (auth !== "Bearer lmb_lib") return new Response("no", { status: 401 })
if (msg.id === undefined) return new Response(null, { status: 202 }) if (msg.id === undefined) return new Response(null, { status: 202 })
const reply = (result: unknown) => Response.json({ jsonrpc: "2.0", id: msg.id, result }) const reply = (result: unknown) => Response.json({ jsonrpc: "2.0", id: msg.id, result })
if (msg.method === "initialize") return reply({ protocolVersion: msg.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "lembas", version: "1.18.0" } }) if (msg.method === "initialize") return reply({ protocolVersion: msg.params.protocolVersion, capabilities: { tools: {}, resources: {} }, serverInfo: { name: "lembas", version: "2.0.0" } })
if (msg.method === "resources/read") {
calls.push({ auth, uri: msg.params.uri, ...(msg.params._meta?.lembas ? { meta: msg.params._meta.lembas } : {}) })
return reply({ contents: [{ uri: msg.params.uri, mimeType: "text/plain", text: msg.params.uri === "lembas://memory" ? memory : "" }] })
}
if (msg.method === "resources/list") return reply({ resources: [{ uri: "lembas://memory", name: "memory" }] })
if (msg.method === "tools/list") if (msg.method === "tools/list")
return reply({ return reply({
tools: [ tools: [
{ name: "memory_add", description: "Remember one fact.", inputSchema: { type: "object", properties: { content: { type: "string" } }, required: ["content"] }, annotations: { readOnlyHint: false } }, { name: "memory", description: "The account's memory.", inputSchema: { type: "object", properties: { action: { type: "string" }, content: { type: "string" } }, required: ["action"] }, annotations: { readOnlyHint: false } },
{ name: "notes_search", description: "Search notes.", inputSchema: { type: "object", properties: { query: { type: "string" } } }, annotations: { readOnlyHint: true } }, { name: "notes_search", description: "Search notes.", inputSchema: { type: "object", properties: { query: { type: "string" } } }, annotations: { readOnlyHint: true } },
], ],
}) })
@@ -46,63 +52,64 @@ function fakeLembasMcp() {
return { base: `http://127.0.0.1:${server.port}`, calls } return { base: `http://127.0.0.1:${server.port}`, calls }
} }
test("the instance's library replaces this machine's, under the tools' own names", async () => { function login(base: string) {
const lembas = fakeLembasMcp()
mkdirSync(join(paths.config, "lembas"), { recursive: true }) mkdirSync(join(paths.config, "lembas"), { recursive: true })
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { ai: { base_url: lembas.base, connection: "ai", logged_in_at: "" } } })) writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { ai: { base_url: base, connection: "ai", logged_in_at: "" } } }))
writeFileSync(join(paths.config, "lembas", "ai.key"), "lmb_lib\n", { mode: 0o600 }) writeFileSync(join(paths.config, "lembas", "ai.key"), "lmb_lib\n", { mode: 0o600 })
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n f:\n type: webui\n url: http://127.0.0.1:1/v1\n models: { f/m: {} }\n", { mode: 0o600 }) writeFileSync(join(paths.config, "connections.yaml"), "connections:\n f:\n type: webui\n url: http://127.0.0.1:1/v1\n models: { f/m: {} }\n", { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\nlibrary: lembas\n") writeFileSync(join(paths.config, "config.yaml"), "model: f/m\nlibrary: lembas\n")
}
test("memory is the server's; notes, knowledge and skills stay this machine's", async () => {
const lembas = fakeLembasMcp()
login(lembas.base)
const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-lib-")), store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } }) const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-lib-")), store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
await app.mcpReady await app.mcpReady
await Bun.sleep(50) await Bun.sleep(50)
const names = app.engine.o.tools.map((t) => t.name) const names = app.engine.o.tools.map((t) => t.name)
expect(names).toContain("memory_add") // The server's memory, once; its notes_search is not taken (only memory comes from /mcp).
expect(names).toContain("notes_search") expect(names.filter((n) => n === "memory").length).toBe(1)
// This machine's library tools are gone; notes_search is the instance's, once.
for (const local of ["memory", "note_view", "note_manage", "skills_list", "skill_manage", "knowledge_search"]) expect(names).not.toContain(local)
expect(names.filter((n) => n === "notes_search").length).toBe(1) expect(names.filter((n) => n === "notes_search").length).toBe(1)
const add = app.engine.o.tools.find((t) => t.name === "memory_add")! for (const local of ["notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage"]) expect(names).toContain(local)
expect(add.permission({ content: "x" } as never, { ...app.engine.o.toolCtx, signal: new AbortController().signal }).class).toBe("interact") const local = (n: string) => app.engine.o.tools.find((t) => t.name === n)!
const out = await add.run({ content: "Prefers tabs." } as never, { ...app.engine.o.toolCtx, signal: new AbortController().signal }) expect(local("notes_search").description).not.toContain("Search notes.")
expect(out.output).toContain("ok: memory_add") const mem = app.engine.o.tools.find((t) => t.name === "memory")!
expect(lembas.calls).toEqual([{ auth: "Bearer lmb_lib", name: "memory_add", args: { content: "Prefers tabs." } }]) expect(mem.description).toBe("The account's memory.")
expect(mem.permission({ action: "add" } as never, { ...app.engine.o.toolCtx, signal: new AbortController().signal }).class).toBe("interact")
const out = await mem.run({ action: "add", content: "Prefers tabs." } as never, { ...app.engine.o.toolCtx, signal: new AbortController().signal })
expect(out.output).toContain("ok: memory")
expect(lembas.calls.filter((c) => c.name)).toEqual([{ auth: "Bearer lmb_lib", name: "memory", args: { action: "add", content: "Prefers tabs." } }])
// The entries for the prompt: read from the resource as the session started.
for (let i = 0; i < 50 && !lembas.calls.some((c) => c.uri); i++) await Bun.sleep(10)
expect(lembas.calls.find((c) => c.uri)).toEqual({ auth: "Bearer lmb_lib", uri: "lembas://memory" })
for (let i = 0; i < 50 && !app.engine.o.system("manual", app.engine.model).includes("Prefers tabs."); i++) await Bun.sleep(10)
expect(app.engine.o.system("manual", app.engine.model)).toContain("MEMORY (the account's)\nPrefers tabs.")
await app.close()
}) })
test("a session the client gave a library scope sends its id on every call to the instance's library; base passes through", async () => { test("a session the client opened sends its id on every memory call and on the resource read", async () => {
const lembas = fakeLembasMcp() const lembas = fakeLembasMcp()
mkdirSync(join(paths.config, "lembas"), { recursive: true }) login(lembas.base)
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: { ai: { base_url: lembas.base, connection: "ai", logged_in_at: "" } } }))
writeFileSync(join(paths.config, "lembas", "ai.key"), "lmb_lib\n", { mode: 0o600 })
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n f:\n type: webui\n url: http://127.0.0.1:1/v1\n models: { f/m: {} }\n", { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\nlibrary: lembas\n")
const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-lib-")), asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } }) const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-lib-")), asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
stops.push(() => void app.close()) stops.push(() => void app.close())
await app.mcpReady await app.mcpReady
await Bun.sleep(50) await Bun.sleep(50)
const search = app.engine.o.tools.find((t) => t.name === "notes_search")! const mem = app.engine.o.tools.find((t) => t.name === "memory")!
const ctx = () => ({ ...app.engine.o.toolCtx, signal: new AbortController().signal }) const ctx = () => ({ ...app.engine.o.toolCtx, signal: new AbortController().signal })
// Nothing said about the session's library: no scope, as before. // A session of this device's own: no scope.
await search.run({ query: "a" } as never, ctx()) await mem.run({ action: "view" } as never, ctx())
expect(lembas.calls[0]).toEqual({ auth: "Bearer lmb_lib", name: "notes_search", args: { query: "a" } }) expect(lembas.calls.filter((c) => c.name)[0]).toEqual({ auth: "Bearer lmb_lib", name: "memory", args: { action: "view" } })
// The client named the bases: the session's id rides on the call as _meta.lembas.scope, and // The client's chat (acp/agent.ts sets it): the id rides on the call, and the entries are read again in it.
// the instance answers within that chat's scope (beside the SDK's own progress token); `base` app.setScope(app.engine.sessionId)
// is the model's word, passed untouched. await mem.run({ action: "view" } as never, ctx())
app.setContext({ library: { bases: [{ id: "kb1", name: "Manuals" }] } }, "ai") expect(lembas.calls.filter((c) => c.name)[1]).toEqual({ auth: "Bearer lmb_lib", name: "memory", args: { action: "view" }, meta: { scope: app.engine.sessionId } })
await search.run({ query: "b", base: "Manuals" } as never, ctx()) for (let i = 0; i < 50 && !lembas.calls.some((c) => c.uri && c.meta); i++) await Bun.sleep(10)
expect(lembas.calls[1]).toEqual({ auth: "Bearer lmb_lib", name: "notes_search", args: { query: "b", base: "Manuals" }, meta: { scope: app.engine.sessionId } }) expect(lembas.calls.find((c) => c.uri && c.meta)).toEqual({ auth: "Bearer lmb_lib", uri: "lembas://memory", meta: { scope: app.engine.sessionId } })
// The prompt names what the session may search. // Knowledge is this machine's: nothing the server holds is named to the model.
expect(app.engine.o.system("manual", app.engine.model)).toContain("Knowledge searches here cover only: Manuals. Finding nothing there means nothing is there, not that the library is empty.")
// Said again with no bases: the account's whole library — nothing named to the model, while the
// scope still rides (the instance answers in the chat's data group).
app.setContext({ library: { bases: [] } }, "ai")
await search.run({ query: "c" } as never, ctx())
expect(lembas.calls[2]!.meta).toEqual({ scope: app.engine.sessionId })
expect(app.engine.o.system("manual", app.engine.model)).not.toContain("Knowledge searches here cover only") expect(app.engine.o.system("manual", app.engine.model)).not.toContain("Knowledge searches here cover only")
expect(app.engine.o.system("manual", app.engine.model)).toContain("search it with knowledge_search")
}) })
test("without a login it says so and keeps this machine's library", () => { test("without a login it says so and keeps this machine's memory", () => {
writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: {} })) writeFileSync(join(paths.config, "lembas.json"), JSON.stringify({ instances: {} }))
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n f:\n type: webui\n url: http://127.0.0.1:1/v1\n models: { f/m: {} }\n", { mode: 0o600 }) writeFileSync(join(paths.config, "connections.yaml"), "connections:\n f:\n type: webui\n url: http://127.0.0.1:1/v1\n models: { f/m: {} }\n", { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\nlibrary: lembas\n") writeFileSync(join(paths.config, "config.yaml"), "model: f/m\nlibrary: lembas\n")