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>
120 lines
8.0 KiB
TypeScript
120 lines
8.0 KiB
TypeScript
// 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"
|
|
import { join } from "node:path"
|
|
import { createApp } from "../src/app.ts"
|
|
import type { AskReply } from "../src/bus/index.ts"
|
|
import { paths } from "../src/config/paths.ts"
|
|
|
|
const stops: (() => void)[] = []
|
|
afterEach(() => {
|
|
for (const s of stops.splice(0)) s()
|
|
})
|
|
|
|
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) {
|
|
const u = new URL(req.url)
|
|
if (u.pathname !== "/mcp") return new Response("no", { status: 404 })
|
|
if (req.method !== "POST") return new Response(null, { status: 405 })
|
|
const msg = (await req.json()) as any
|
|
const auth = req.headers.get("authorization") ?? ""
|
|
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: {}, 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", 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 } },
|
|
],
|
|
})
|
|
if (msg.method === "tools/call") {
|
|
// `_meta.lembas` alone: the SDK adds a progress token of its own beside it.
|
|
calls.push({ auth, name: msg.params.name, args: msg.params.arguments, ...(msg.params._meta?.lembas ? { meta: msg.params._meta.lembas } : {}) })
|
|
return reply({ content: [{ type: "text", text: `ok: ${msg.params.name}` }], isError: false })
|
|
}
|
|
return Response.json({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "no" } })
|
|
},
|
|
})
|
|
stops.push(() => server.stop(true))
|
|
return { base: `http://127.0.0.1:${server.port}`, calls }
|
|
}
|
|
|
|
function login(base: string) {
|
|
mkdirSync(join(paths.config, "lembas"), { recursive: true })
|
|
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<AskReply> => ({ kind: "once" }) } })
|
|
await app.mcpReady
|
|
await Bun.sleep(50)
|
|
const names = app.engine.o.tools.map((t) => t.name)
|
|
// 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)
|
|
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 opened sends its id on every memory call and on the resource read", async () => {
|
|
const lembas = fakeLembasMcp()
|
|
login(lembas.base)
|
|
const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-lib-")), asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
|
stops.push(() => void app.close())
|
|
await app.mcpReady
|
|
await Bun.sleep(50)
|
|
const mem = app.engine.o.tools.find((t) => t.name === "memory")!
|
|
const ctx = () => ({ ...app.engine.o.toolCtx, signal: new AbortController().signal })
|
|
// 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")
|
|
})
|
|
|
|
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")
|
|
const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-lib-")), store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
|
expect(app.loaded.warnings.join(" ")).toContain("not logged in")
|
|
expect(app.engine.o.tools.map((t) => t.name)).toContain("memory")
|
|
})
|