From a73adc01f03553dddd77990f5465031e6c1141bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 27 Oct 2026 21:07:37 +0000 Subject: [PATCH] Only memory comes from the server's /mcp; notes, knowledge and skills stay local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- schema/config.schema.json | 2 +- src/acp/agent.ts | 13 ++--- src/app.ts | 78 ++++++++++++++++++++---------- src/config/schema.ts | 2 +- src/lembas/personal.ts | 7 +-- src/mcp/index.ts | 19 ++++++-- src/tool/tool.ts | 4 +- tests/acp-v2.test.ts | 7 +-- tests/harness.test.ts | 2 +- tests/lembas-library.test.ts | 93 +++++++++++++++++++----------------- 10 files changed, 134 insertions(+), 93 deletions(-) diff --git a/schema/config.schema.json b/schema/config.schema.json index 7f6e74a..0fdd475 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -996,7 +996,7 @@ "type": "boolean" }, "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", "enum": [ "local", diff --git a/src/acp/agent.ts b/src/acp/agent.ts index d0e9ba4..e9c6fb2 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -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) diff --git a/src/app.ts b/src/app.ts index 5b7ac4c..ef08f89 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 = "" diff --git a/src/config/schema.ts b/src/config/schema.ts index bfe74cd..bacb8f1 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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//) 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."), diff --git a/src/lembas/personal.ts b/src/lembas/personal.ts index 44f767b..b616ab9 100644 --- a/src/lembas/personal.ts +++ b/src/lembas/personal.ts @@ -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. */ diff --git a/src/mcp/index.ts b/src/mcp/index.ts index e869bf3..57e9609 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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____, 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 { + 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, ...(scope ? { _meta: { lembas: { scope } } } : {}) }, undefined, { signal: ctx.signal, diff --git a/src/tool/tool.ts b/src/tool/tool.ts index 1fde9ae..f1a3a6d 100644 --- a/src/tool/tool.ts +++ b/src/tool/tool.ts @@ -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 - /** 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. */ diff --git a/tests/acp-v2.test.ts b/tests/acp-v2.test.ts index c84736c..e0ec108 100644 --- a/tests/acp-v2.test.ts +++ b/tests/acp-v2.test.ts @@ -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") }) -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" })] }]) config(fake.url, "personality: concise\ninstructions: local words\n") 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("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.") - // The library's scope is named only where its tools are offered (none here: no local base, no - // instance library) — nothing unfilled reaches the model either way. + // The instance's knowledge bases are not the device's (spec 3.0.0): an older client's `library` + // names nothing to the model — nothing unfilled reaches it either. + expect(system()).not.toContain("Manuals") expect(system()).not.toContain("{{") // 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: "" } } } }) diff --git a/tests/harness.test.ts b/tests/harness.test.ts index b755234..f1d1809 100644 --- a/tests/harness.test.ts +++ b/tests/harness.test.ts @@ -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") } } - 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) expect(withContext).not.toContain("{{") expect(withContext).toContain("## Instructions from example\n\nBe brief.") diff --git a/tests/lembas-library.test.ts b/tests/lembas-library.test.ts index 36566cf..4a477ab 100644 --- a/tests/lembas-library.test.ts +++ b/tests/lembas-library.test.ts @@ -1,5 +1,6 @@ -// library: lembas: the account's library through the instance's /mcp, in place of this -// machine's — the tools under their own names, as a LLeMbas chat has them, with the device token. +// library: lembas (harness spec 3.0.0, acp.md *Memory*): the account's memory through the server's +// /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 { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" @@ -13,8 +14,8 @@ afterEach(() => { for (const s of stops.splice(0)) s() }) -function fakeLembasMcp() { - const calls: { auth: string; name?: string; args?: unknown; meta?: unknown }[] = [] +function fakeLembasMcp(memory = "MEMORY (the account's)\nPrefers tabs.") { + const calls: { auth: string; name?: string; args?: unknown; meta?: unknown; uri?: string }[] = [] const server = Bun.serve({ port: 0, async fetch(req) { @@ -26,11 +27,16 @@ function fakeLembasMcp() { if (auth !== "Bearer lmb_lib") return new Response("no", { status: 401 }) if (msg.id === undefined) return new Response(null, { status: 202 }) 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") return reply({ 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 } }, ], }) @@ -46,63 +52,64 @@ function fakeLembasMcp() { 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 () => { - const lembas = fakeLembasMcp() +function login(base: string) { 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, "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") +} + +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 => ({ kind: "once" }) } }) await app.mcpReady await Bun.sleep(50) const names = app.engine.o.tools.map((t) => t.name) - expect(names).toContain("memory_add") - expect(names).toContain("notes_search") - // 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) + // The server's memory, once; its notes_search is not taken (only memory comes from /mcp). + expect(names.filter((n) => n === "memory").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")! - expect(add.permission({ content: "x" } as never, { ...app.engine.o.toolCtx, signal: new AbortController().signal }).class).toBe("interact") - const out = await add.run({ content: "Prefers tabs." } as never, { ...app.engine.o.toolCtx, signal: new AbortController().signal }) - expect(out.output).toContain("ok: memory_add") - expect(lembas.calls).toEqual([{ auth: "Bearer lmb_lib", name: "memory_add", args: { content: "Prefers tabs." } }]) + for (const local of ["notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage"]) expect(names).toContain(local) + const local = (n: string) => app.engine.o.tools.find((t) => t.name === n)! + expect(local("notes_search").description).not.toContain("Search notes.") + const mem = app.engine.o.tools.find((t) => t.name === "memory")! + 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() - 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", "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") + login(lembas.base) const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-lib-")), asker: { ask: async (): Promise => ({ kind: "once" }) } }) stops.push(() => void app.close()) await app.mcpReady 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 }) - // Nothing said about the session's library: no scope, as before. - await search.run({ query: "a" } as never, ctx()) - expect(lembas.calls[0]).toEqual({ auth: "Bearer lmb_lib", name: "notes_search", args: { query: "a" } }) - // The client named the bases: the session's id rides on the call as _meta.lembas.scope, and - // the instance answers within that chat's scope (beside the SDK's own progress token); `base` - // is the model's word, passed untouched. - app.setContext({ library: { bases: [{ id: "kb1", name: "Manuals" }] } }, "ai") - await search.run({ query: "b", base: "Manuals" } as never, ctx()) - expect(lembas.calls[1]).toEqual({ auth: "Bearer lmb_lib", name: "notes_search", args: { query: "b", base: "Manuals" }, meta: { scope: app.engine.sessionId } }) - // The prompt names what the session may search. - 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 }) + // A session of this device's own: no scope. + await mem.run({ action: "view" } as never, ctx()) + expect(lembas.calls.filter((c) => c.name)[0]).toEqual({ auth: "Bearer lmb_lib", name: "memory", args: { action: "view" } }) + // The client's chat (acp/agent.ts sets it): the id rides on the call, and the entries are read again in it. + app.setScope(app.engine.sessionId) + await mem.run({ action: "view" } as never, ctx()) + expect(lembas.calls.filter((c) => c.name)[1]).toEqual({ auth: "Bearer lmb_lib", name: "memory", args: { action: "view" }, meta: { scope: app.engine.sessionId } }) + for (let i = 0; i < 50 && !lembas.calls.some((c) => c.uri && c.meta); i++) await Bun.sleep(10) + expect(lembas.calls.find((c) => c.uri && c.meta)).toEqual({ auth: "Bearer lmb_lib", uri: "lembas://memory", meta: { scope: app.engine.sessionId } }) + // Knowledge is this machine's: nothing the server holds is named to the model. 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, "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")