import { JsonRpcClient } from "./json-rpc-client.ts"; import { LoreProcessError, LoreRemoteError } from "./errors.ts"; import { decodeStructuredToolResult } from "./lore-protocol.ts"; import type { JsonObject, JsonValue, LoreCallOptions, LoreProcessConfig, LoreStructuredToolResult, McpTool } from "./types.ts"; type QueueItem = { run: () => Promise; resolve: (value: T) => void; reject: (error: Error) => void; }; export class LoreClient { private readonly config: LoreProcessConfig; private rpc?: JsonRpcClient; private started = false; private queue: Promise = Promise.resolve(); private lifecycleQueue: Promise = Promise.resolve(); private staleAfterFailure = false; private desiredState: "running" | "stopped" = "stopped"; constructor(config: LoreProcessConfig) { this.config = config; } async start(): Promise { this.desiredState = "running"; await this.enqueueLifecycle(async () => { await this.startUnlocked(); }); } async stop(): Promise { this.desiredState = "stopped"; await this.enqueueLifecycle(async () => { await this.stopUnlocked(); }); await this.queue.catch(() => undefined); } async restart(): Promise { this.desiredState = "running"; await this.enqueueLifecycle(async () => { await this.stopUnlocked(); await this.startUnlocked(); }); } async listTools(): Promise { const result = await this.enqueue(() => this.rawRequest("tools/list", {}, this.config.startupTimeoutMs)); if (!result || typeof result !== "object" || !Array.isArray((result as { tools?: unknown }).tools)) { throw new Error("Lore tools/list returned an invalid result"); } return (result as { tools: McpTool[] }).tools.map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, })); } async callStructured(name: string, args: unknown, options: LoreCallOptions = {}): Promise { const timeoutMs = options.timeoutMs ?? this.config.toolTimeoutMs[name] ?? this.config.defaultToolTimeoutMs; const result = await this.enqueue(() => this.rawRequest( "lore/tools/callStructured", { name, arguments: args === undefined ? {} : args } as JsonObject, timeoutMs, options.signal, ), ); return decodeStructuredToolResult(result); } async getCachedDefinitions(): Promise { const result = await this.enqueue(() => this.rawRequest("lore/knowledge/getCachedDefinitions", {}, this.config.defaultToolTimeoutMs), ); if (!result || typeof result !== "object" || !Array.isArray((result as { hashes?: unknown }).hashes)) { throw new Error("Lore getCachedDefinitions returned an invalid result"); } return (result as { hashes: unknown[] }).hashes.map((hash) => { if (typeof hash !== "string") { throw new Error("Lore getCachedDefinitions returned a non-string hash"); } return hash; }); } async setCachedDefinitions(hashes: string[]): Promise { await this.enqueue(() => this.rawRequest( "lore/knowledge/setCachedDefinitions", { hashes } as JsonObject, this.config.defaultToolTimeoutMs, ), ); } private async verifyPrivateKnowledgeRpc(rpc = this.rpc): Promise { if (!rpc) { throw new Error("Lore server is not running"); } const result = await this.rawRequestOn(rpc, "lore/knowledge/getCachedDefinitions", {}, this.config.startupTimeoutMs); if (!result || typeof result !== "object" || !Array.isArray((result as { hashes?: unknown }).hashes)) { throw new Error("Lore server does not support lore/knowledge/getCachedDefinitions"); } } private async verifyStructuredToolRpc(rpc = this.rpc): Promise { if (!rpc) { throw new Error("Lore server is not running"); } try { await this.rawRequestOn( rpc, "lore/tools/callStructured", { name: "__pi_lore_extension_protocol_probe__", arguments: {} } as JsonObject, this.config.startupTimeoutMs, ); } catch (error) { if (error instanceof LoreRemoteError && error.rpcCode !== -32601) { return; } throw new Error("Lore server does not support lore/tools/callStructured"); } } private enqueue(run: () => Promise): Promise { const item = new Promise((resolve, reject) => { const queueItem: QueueItem = { run, resolve, reject }; this.queue = this.queue.then(() => this.runQueueItem(queueItem), () => this.runQueueItem(queueItem)); }); return item; } private async runQueueItem(item: QueueItem): Promise { try { if (this.desiredState === "stopped") { throw new LoreProcessError("Lore client is stopped"); } if (!this.started || !this.rpc?.isRunning || this.staleAfterFailure) { if (this.desiredState !== "running") { throw new LoreProcessError("Lore client is stopped"); } await this.ensureRunning(); } item.resolve(await item.run()); } catch (error) { if (!(error instanceof LoreRemoteError)) { this.staleAfterFailure = true; } item.reject(error instanceof Error ? error : new Error(String(error))); } } private async ensureRunning(): Promise { await this.enqueueLifecycle(async () => { if (this.desiredState !== "running") { throw new LoreProcessError("Lore client is stopped"); } if (this.staleAfterFailure || !this.started || !this.rpc?.isRunning) { await this.stopUnlocked(); if (this.desiredState !== "running") { throw new LoreProcessError("Lore client is stopped"); } await this.startUnlocked(); } }); } private async startUnlocked(): Promise { if (this.started && this.rpc?.isRunning) { return; } const rpc = new JsonRpcClient(this.config); this.rpc = rpc; rpc.start(); try { await this.rawRequestOn( rpc, "initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "pi-lore-extension", version: "1.0.8" }, }, this.config.startupTimeoutMs, ); rpc.notify("notifications/initialized"); await this.verifyPrivateKnowledgeRpc(rpc); await this.verifyStructuredToolRpc(rpc); this.started = true; this.staleAfterFailure = false; } catch (error) { await this.stopRpcIfCurrent(rpc); throw error; } } private async stopUnlocked(): Promise { this.started = false; const rpc = this.rpc; if (!rpc) { return; } await rpc.stop(); if (this.rpc === rpc) { this.rpc = undefined; } } private async rawRequest( method: string, params: JsonValue | undefined, timeoutMs: number, signal?: AbortSignal, ): Promise { if (!this.rpc?.isRunning) { throw new Error("Lore process is not running"); } return this.rpc.request(method, params, timeoutMs, signal); } private rawRequestOn( rpc: JsonRpcClient, method: string, params: JsonValue | undefined, timeoutMs: number, signal?: AbortSignal, ): Promise { return rpc.request(method, params, timeoutMs, signal); } private async stopRpcIfCurrent(rpc: JsonRpcClient): Promise { await rpc.stop(); if (this.rpc === rpc) { this.rpc = undefined; this.started = false; } } private enqueueLifecycle(operation: () => Promise): Promise { const next = this.lifecycleQueue.then(operation, operation); this.lifecycleQueue = next.catch(() => undefined); return next; } }