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
@@ -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
@@ -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
@@ -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 = ""
|
||||||
|
|||||||
@@ -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."),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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: "" } } } })
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user