diff --git a/packages/agentmask/openclaw-plugin-discovery/VALIDATION.md b/packages/agentmask/openclaw-plugin-discovery/VALIDATION.md index 3600e5cc44..b17795eae2 100644 --- a/packages/agentmask/openclaw-plugin-discovery/VALIDATION.md +++ b/packages/agentmask/openclaw-plugin-discovery/VALIDATION.md @@ -14,8 +14,8 @@ plus its sample-services daemon on the laptop. │ relay (libp2p) — writes ~/.libp2p-relay/relay.addr │ │ │ │ matcher daemon — OCAP_HOME=~/.ocap (default) │ -│ (subcluster running the matcher vat, │ -│ plus the llm-bridge process) │ +│ (subcluster running the matcher vat; the daemon │ +│ registers a languageModelService from llm.json) │ │ │ │ consumer daemon — OCAP_HOME=~/.ocap-consumer │ │ (no subclusters; hosts the openclaw plugin's │ @@ -81,10 +81,12 @@ Four things must be live before starting this validation: [Restarting](#restarting) section below for what this command actually does and when you should use a different one. - The matcher ranks via an LLM through `@ocap/llm-bridge`, which - talks to the openclaw gateway's OpenAI-compatible - `/v1/chat/completions` endpoint. That adds two requirements, - described in the next two subsections. + The matcher ranks via the daemon's `languageModelService` + kernel service, which talks to the openclaw gateway's + OpenAI-compatible `/v1/chat/completions` endpoint + (`start-matcher.sh` provisions the daemon's `~/.ocap/llm.json` + for this). That adds two requirements, described in the next + two subsections. 3. **Sample-services daemon** on the laptop (the same machine as the MetaMask extension), started via @@ -129,7 +131,7 @@ already exists** — copy the existing value and reuse it; clobbering the token would invalidate any other clients pointed at the gateway. Only the `chatCompletions` endpoint flag is reliably new. -### Bridge env vars +### LLM env vars In the shell that runs `start-matcher.sh` (set these in your shell profile alongside `LIBP2P_RELAY_PUBLIC_IP`): @@ -141,25 +143,15 @@ export OPENCLAW_GATEWAY_TOKEN= # export OPENCLAW_AGENT_MODEL=openclaw # default ``` -The bridge runs as a background process beside the matcher daemon -(pid file at `~/.ocap/matcher-llm-bridge.pid`, log at -`~/.ocap/matcher-llm-bridge.log`). `start-matcher.sh` reaps any -previous bridge before spawning a new one; `reset-everything.sh` -tears it down alongside the daemons. - -The bridge log captures both halves of every round trip so you can -see exactly what's flowing to and from the LLM. Each ingest or -query produces: - -```text -[llm-bridge] -> ingest: {full request JSON} -[llm-bridge] → chat-completions request: {full messages array sent to openclaw} -[llm-bridge] ← chat-completions reply: {full response body from openclaw} -[llm-bridge] <- ingested (svc:0) -``` +The LLM calls run inside the matcher daemon itself (no separate +bridge process). The daemon reads `~/.ocap/llm.json` at startup and +registers the `languageModelService` kernel service; the matcher vat +sends one chat-completion request per `findServices` call, carrying +the full current registry in the prompt. Registrations are purely +local to the vat and involve no LLM traffic. -`tail -f ~/.ocap/matcher-llm-bridge.log` while you exercise the -matcher to watch prompts and replies in real time. +`tail -f ~/.ocap/daemon.log` while you exercise the matcher to watch +ranking activity (the matcher vat logs with a `[matcher]` prefix). ## Restarting @@ -355,15 +347,15 @@ pre-configured. Expected tool: `discovery_find_services(description: "sign a message with my wallet")`. Expected response: PersonalMessageSigner - as the top candidate. The LLM bridge ranks candidates against the + as the top candidate. The LLM ranks candidates against the query, so the exact response shape depends on the model's output, but a competently-configured model should pick PMS clearly and either omit Echo/RandomNumber or rank them well below it. Each returned candidate carries a `contact (public): ocap:…` URL plus a `rationale` string in the model's own words. If you see a - "bridge query error" or "bridge ingest error" message instead, - check `~/.ocap/matcher-llm-bridge.log` and the openclaw gateway - config (token + chatCompletions endpoint enabled). + ranking error propagated from the LLM instead, check + `~/.ocap/daemon.log` and the openclaw gateway config (token + + chatCompletions endpoint enabled). 3. **Inspect PMS.** @@ -454,15 +446,14 @@ Light-touch. Prompts the agent to hit edge cases. ## Known limitations going in -- Matcher `findServices` uses an LLM-backed Stage-2 ranker via - `@ocap/llm-bridge`, which calls openclaw's - `/v1/chat/completions`. The bridge is started by - `start-matcher.sh` and writes to `~/.ocap/matcher-llm-bridge.log`. - Bridge errors propagate to the consumer rather than falling back - to a heuristic ranker — silent fallbacks would hide LLM-side - problems during development. Stage-3 RAG-style indexing is the - next planned step; today every registration's full digest sits - in the matcher's LLM context window. +- Matcher `findServices` uses an LLM-backed Stage-2 ranker via the + daemon's `languageModelService` kernel service, which calls + openclaw's `/v1/chat/completions`. LLM errors propagate to the + consumer rather than falling back to a heuristic ranker — silent + fallbacks would hide LLM-side problems during development. + Stage-3 RAG-style indexing is the next planned step; today every + registered service's full digest rides along in each ranking + prompt. - Matcher URL is stable across plain daemon restarts of the same OCAP home (durable `publicFacet` kref + persisted peer ID and encryption key), but **re-running `start-matcher.sh` allocates a diff --git a/packages/kernel-agents/package.json b/packages/kernel-agents/package.json index bc1358fae4..180d5b7e02 100644 --- a/packages/kernel-agents/package.json +++ b/packages/kernel-agents/package.json @@ -194,10 +194,10 @@ "dependencies": { "@endo/eventual-send": "^1.3.4", "@metamask/kernel-errors": "workspace:^", + "@metamask/kernel-language-model-service": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/superstruct": "^3.2.1", - "@ocap/kernel-language-model-service": "workspace:^", "partial-json": "^0.1.7", "ses": "^1.14.0" } diff --git a/packages/kernel-agents/src/agent.ts b/packages/kernel-agents/src/agent.ts index 74ffe74b64..eda9ebd3f0 100644 --- a/packages/kernel-agents/src/agent.ts +++ b/packages/kernel-agents/src/agent.ts @@ -1,6 +1,6 @@ +import type { LanguageModel } from '@metamask/kernel-language-model-service'; import { mergeDisjointRecords } from '@metamask/kernel-utils'; import type { Logger } from '@metamask/logger'; -import type { LanguageModel } from '@ocap/kernel-language-model-service'; import { doAttempt } from './attempt.ts'; import { TaskManager } from './task.ts'; diff --git a/packages/kernel-agents/src/attempt.test.ts b/packages/kernel-agents/src/attempt.test.ts index 02afb192fd..02f9f119b9 100644 --- a/packages/kernel-agents/src/attempt.test.ts +++ b/packages/kernel-agents/src/attempt.test.ts @@ -1,7 +1,7 @@ import '@ocap/repo-tools/test-utils/mock-endoify'; +import type { LanguageModel } from '@metamask/kernel-language-model-service'; import type { Logger } from '@metamask/logger'; -import type { LanguageModel } from '@ocap/kernel-language-model-service'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { doAttempt } from './attempt.ts'; diff --git a/packages/kernel-agents/src/attempt.ts b/packages/kernel-agents/src/attempt.ts index ff6bcafeeb..01fef56805 100644 --- a/packages/kernel-agents/src/attempt.ts +++ b/packages/kernel-agents/src/attempt.ts @@ -1,6 +1,6 @@ import { SampleGenerationError } from '@metamask/kernel-errors'; +import type { LanguageModel } from '@metamask/kernel-language-model-service'; import type { Logger } from '@metamask/logger'; -import type { LanguageModel } from '@ocap/kernel-language-model-service'; import type { Message, MessageTypeBase } from './types/messages.ts'; import type { PREP, Progress } from './types.ts'; diff --git a/packages/kernel-agents/src/strategies/chat-agent.test.ts b/packages/kernel-agents/src/strategies/chat-agent.test.ts index f9f068efdc..839ec61f18 100644 --- a/packages/kernel-agents/src/strategies/chat-agent.test.ts +++ b/packages/kernel-agents/src/strategies/chat-agent.test.ts @@ -4,7 +4,7 @@ import type { ChatMessage, ChatResult, ToolCall, -} from '@ocap/kernel-language-model-service'; +} from '@metamask/kernel-language-model-service'; import { describe, expect, it, vi } from 'vitest'; import { makeChatAgent } from './chat-agent.ts'; diff --git a/packages/kernel-agents/src/strategies/chat-agent.ts b/packages/kernel-agents/src/strategies/chat-agent.ts index 99c6a187c7..64a59543c3 100644 --- a/packages/kernel-agents/src/strategies/chat-agent.ts +++ b/packages/kernel-agents/src/strategies/chat-agent.ts @@ -1,10 +1,10 @@ -import type { Logger } from '@metamask/logger'; import type { ChatMessage, ChatResult, Tool, -} from '@ocap/kernel-language-model-service'; -import { parseToolArguments } from '@ocap/kernel-language-model-service/utils/parse-tool-arguments'; +} from '@metamask/kernel-language-model-service'; +import { parseToolArguments } from '@metamask/kernel-language-model-service/utils/parse-tool-arguments'; +import type { Logger } from '@metamask/logger'; import { extractCapabilitySchemas } from '../capabilities/capability.ts'; import { validateCapabilityArgs } from '../capabilities/validate-capability-args.ts'; diff --git a/packages/kernel-cli/CHANGELOG.md b/packages/kernel-cli/CHANGELOG.md index 2e32a111dc..a1b3356471 100644 --- a/packages/kernel-cli/CHANGELOG.md +++ b/packages/kernel-cli/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The daemon registers a `languageModelService` kernel service at startup when `llm.json` is present in the OCAP home, backed by any Open /v1-compatible endpoint (`provider`, `baseUrl`, and an API key indirected through `apiKeyEnv` or `apiKeyFile`); subclusters request it by listing the service name in their cluster config ([#955](https://github.com/MetaMask/ocap-kernel/pull/955)) - Add global `--home ` flag overriding `$OCAP_HOME` for the duration of one invocation, so multiple OCAP daemons can run side by side without juggling environment variables ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) - Add `--public-ip ` to `kernel relay start` (also reads `$LIBP2P_RELAY_PUBLIC_IP`); the relay announces the supplied IPv4 alongside its bound NIC addresses, so a NAT-backed VPS can be reached from off-host peers ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) - More legible output from `kernel relay status` ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) diff --git a/packages/kernel-cli/package.json b/packages/kernel-cli/package.json index b5031b6901..d3eb4ec472 100644 --- a/packages/kernel-cli/package.json +++ b/packages/kernel-cli/package.json @@ -47,10 +47,12 @@ }, "dependencies": { "@endo/promise-kit": "^1.1.13", + "@metamask/kernel-language-model-service": "workspace:^", "@metamask/kernel-node-runtime": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", + "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.9.0", "@types/node": "^22.13.1", "acorn": "^8.15.0", diff --git a/packages/kernel-cli/src/commands/daemon-entry.ts b/packages/kernel-cli/src/commands/daemon-entry.ts index 0a43d64a03..2639294fcf 100644 --- a/packages/kernel-cli/src/commands/daemon-entry.ts +++ b/packages/kernel-cli/src/commands/daemon-entry.ts @@ -9,6 +9,7 @@ import { join } from 'node:path'; import { getOcapHome } from '../ocap-home.ts'; import { isProcessAlive } from '../utils.ts'; +import { makeLlmKernelService, readLlmConfig } from './llm-config.ts'; main().catch((error) => { process.stderr.write(`Daemon fatal: ${String(error)}\n`); @@ -59,6 +60,14 @@ async function main(): Promise { let handle: DaemonHandle; try { await kernel.initIdentity(); + const llmConfig = await readLlmConfig(ocapDir); + if (llmConfig) { + const { name, service } = await makeLlmKernelService(llmConfig); + kernel.registerKernelServiceObject(name, service); + logger.info( + `Registered kernel service "${name}" (${llmConfig.provider} at ${llmConfig.baseUrl})`, + ); + } await writeFile(pidPath, String(process.pid)); handle = await startDaemon({ diff --git a/packages/kernel-cli/src/commands/llm-config.test.ts b/packages/kernel-cli/src/commands/llm-config.test.ts new file mode 100644 index 0000000000..ec8c7c67ab --- /dev/null +++ b/packages/kernel-cli/src/commands/llm-config.test.ts @@ -0,0 +1,140 @@ +import '@metamask/kernel-shims/endoify'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + LLM_CONFIG_FILENAME, + makeLlmKernelService, + readLlmConfig, + resolveLlmApiKey, +} from './llm-config.ts'; + +describe('llm-config', () => { + let ocapDir: string; + + beforeEach(async () => { + ocapDir = await mkdtemp(join(tmpdir(), 'llm-config-test-')); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await rm(ocapDir, { recursive: true, force: true }); + }); + + /** + * Write an `llm.json` into the test OCAP home. + * + * @param contents - The raw file contents. + */ + async function writeConfig(contents: string): Promise { + await writeFile(join(ocapDir, LLM_CONFIG_FILENAME), contents); + } + + describe('readLlmConfig', () => { + it('returns undefined when no config file exists', async () => { + expect(await readLlmConfig(ocapDir)).toBeUndefined(); + }); + + it('returns the parsed config', async () => { + await writeConfig( + JSON.stringify({ + provider: 'open-v1', + baseUrl: 'http://127.0.0.1:18789', + apiKeyEnv: 'TEST_LLM_TOKEN', + }), + ); + expect(await readLlmConfig(ocapDir)).toStrictEqual({ + provider: 'open-v1', + baseUrl: 'http://127.0.0.1:18789', + apiKeyEnv: 'TEST_LLM_TOKEN', + }); + }); + + it('throws on invalid JSON', async () => { + await writeConfig('{not json'); + await expect(readLlmConfig(ocapDir)).rejects.toThrow(/Invalid JSON/u); + }); + + it.each([ + ['an unknown provider', { provider: 'closed-v9', baseUrl: 'x' }], + ['a missing baseUrl', { provider: 'open-v1' }], + [ + 'an unknown key', + { provider: 'open-v1', baseUrl: 'x', apiKey: 'inline-secret' }, + ], + ])('throws on %s', async (_case, config) => { + await writeConfig(JSON.stringify(config)); + await expect(readLlmConfig(ocapDir)).rejects.toThrow( + /Invalid LLM config/u, + ); + }); + }); + + describe('resolveLlmApiKey', () => { + it('reads the key from the named env var', async () => { + vi.stubEnv('TEST_LLM_TOKEN', 'sekrit'); + expect( + await resolveLlmApiKey({ + provider: 'open-v1', + baseUrl: 'x', + apiKeyEnv: 'TEST_LLM_TOKEN', + }), + ).toBe('sekrit'); + }); + + it('throws when the named env var is unset', async () => { + await expect( + resolveLlmApiKey({ + provider: 'open-v1', + baseUrl: 'x', + apiKeyEnv: 'TEST_LLM_TOKEN_UNSET', + }), + ).rejects.toThrow(/unset or empty/u); + }); + + it('reads the key from the named file, trimmed', async () => { + const keyPath = join(ocapDir, 'token.txt'); + await writeFile(keyPath, ' file-sekrit\n'); + expect( + await resolveLlmApiKey({ + provider: 'open-v1', + baseUrl: 'x', + apiKeyFile: keyPath, + }), + ).toBe('file-sekrit'); + }); + + it('throws when the named file is empty', async () => { + const keyPath = join(ocapDir, 'token.txt'); + await writeFile(keyPath, '\n'); + await expect( + resolveLlmApiKey({ + provider: 'open-v1', + baseUrl: 'x', + apiKeyFile: keyPath, + }), + ).rejects.toThrow(/is empty/u); + }); + + it('returns undefined when the config names no key source', async () => { + expect( + await resolveLlmApiKey({ provider: 'open-v1', baseUrl: 'x' }), + ).toBeUndefined(); + }); + }); + + describe('makeLlmKernelService', () => { + it('builds a registrable languageModelService', async () => { + vi.stubEnv('TEST_LLM_TOKEN', 'sekrit'); + const { name, service } = await makeLlmKernelService({ + provider: 'open-v1', + baseUrl: 'http://127.0.0.1:18789', + apiKeyEnv: 'TEST_LLM_TOKEN', + }); + expect(name).toBe('languageModelService'); + expect(service).toHaveProperty('chat'); + }); + }); +}); diff --git a/packages/kernel-cli/src/commands/llm-config.ts b/packages/kernel-cli/src/commands/llm-config.ts new file mode 100644 index 0000000000..972c3330ec --- /dev/null +++ b/packages/kernel-cli/src/commands/llm-config.ts @@ -0,0 +1,130 @@ +import { + makeKernelLanguageModelService, + makeOpenV1NodejsService, +} from '@metamask/kernel-language-model-service'; +import type { Infer } from '@metamask/superstruct'; +import { + assert, + exactOptional, + literal, + object, + string, +} from '@metamask/superstruct'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * Filename of the daemon's language model configuration, resolved + * relative to the OCAP home directory. + */ +export const LLM_CONFIG_FILENAME = 'llm.json'; + +/** + * Daemon language model configuration, read from `llm.json` in the OCAP + * home directory. When present, the daemon registers a + * `languageModelService` kernel service backed by the configured + * provider, which subclusters can request by listing the service name + * in their cluster config. + * + * The API key is deliberately indirected through an environment + * variable or file so the token itself never lives in the config. + */ +export const LlmConfigStruct = object({ + /** The provider protocol. Only Open /v1-compatible endpoints for now. */ + provider: literal('open-v1'), + /** Base URL of the API (e.g. an openclaw gateway or api.openai.com). */ + baseUrl: string(), + /** Name of an environment variable holding the API key. */ + apiKeyEnv: exactOptional(string()), + /** Path of a file whose (trimmed) contents are the API key. */ + apiKeyFile: exactOptional(string()), +}); + +export type LlmConfig = Infer; + +/** + * Read and validate the LLM config from the OCAP home directory. + * + * A missing file means "no language model service" and returns + * `undefined`; a present-but-invalid file throws so that a config typo + * disables the daemon loudly rather than the service silently. + * + * @param ocapDir - The OCAP home directory. + * @returns The validated config, or `undefined` if no config file exists. + */ +export async function readLlmConfig( + ocapDir: string, +): Promise { + const configPath = join(ocapDir, LLM_CONFIG_FILENAME); + let raw: string; + try { + raw = await readFile(configPath, 'utf-8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid JSON in ${configPath}: ${String(error)}`); + } + try { + assert(parsed, LlmConfigStruct); + } catch (error) { + throw new Error(`Invalid LLM config in ${configPath}: ${String(error)}`); + } + return parsed; +} + +/** + * Resolve the API key designated by the config, if any. + * + * @param config - The LLM config. + * @returns The API key, or `undefined` when the config names none (for + * gateways that don't require auth). + * @throws If the config names an env var or file that is missing or empty. + */ +export async function resolveLlmApiKey( + config: LlmConfig, +): Promise { + if (config.apiKeyEnv !== undefined) { + const value = process.env[config.apiKeyEnv]; + if (!value) { + throw new Error( + `LLM config names env var "${config.apiKeyEnv}", but it is unset or empty`, + ); + } + return value; + } + if (config.apiKeyFile !== undefined) { + const value = (await readFile(config.apiKeyFile, 'utf-8')).trim(); + if (!value) { + throw new Error(`LLM config key file "${config.apiKeyFile}" is empty`); + } + return value; + } + return undefined; +} + +/** + * Build the `languageModelService` kernel service object for the + * configured provider. + * + * @param config - The LLM config. + * @returns A `{ name, service }` pair for + * `kernel.registerKernelServiceObject(name, service)`. + */ +export async function makeLlmKernelService( + config: LlmConfig, +): Promise<{ name: string; service: object }> { + const apiKey = await resolveLlmApiKey(config); + const { chat } = makeOpenV1NodejsService({ + endowments: { fetch: globalThis.fetch.bind(globalThis) }, + baseUrl: config.baseUrl, + ...(apiKey === undefined ? {} : { apiKey }), + }); + return makeKernelLanguageModelService(chat); +} diff --git a/packages/kernel-cli/tsconfig.build.json b/packages/kernel-cli/tsconfig.build.json index 0aac5d7a6c..7c125bde82 100644 --- a/packages/kernel-cli/tsconfig.build.json +++ b/packages/kernel-cli/tsconfig.build.json @@ -11,7 +11,8 @@ }, "references": [ { "path": "../logger/tsconfig.build.json" }, - { "path": "../kernel-utils/tsconfig.build.json" } + { "path": "../kernel-utils/tsconfig.build.json" }, + { "path": "../kernel-language-model-service/tsconfig.build.json" } ], "files": [], "include": ["./src"] diff --git a/packages/kernel-cli/tsconfig.json b/packages/kernel-cli/tsconfig.json index 88682225b8..a944884dfd 100644 --- a/packages/kernel-cli/tsconfig.json +++ b/packages/kernel-cli/tsconfig.json @@ -9,7 +9,8 @@ "references": [ { "path": "../logger" }, { "path": "../repo-tools" }, - { "path": "../kernel-utils" } + { "path": "../kernel-utils" }, + { "path": "../kernel-language-model-service" } ], "include": [ "../../vitest.config.ts", diff --git a/packages/kernel-language-model-service/CHANGELOG.md b/packages/kernel-language-model-service/CHANGELOG.md index 0c82cb1ed6..61e6cda16a 100644 --- a/packages/kernel-language-model-service/CHANGELOG.md +++ b/packages/kernel-language-model-service/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Initial release as a published package (previously the private `@ocap/kernel-language-model-service`) ([#955](https://github.com/MetaMask/ocap-kernel/pull/955)) + [Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/kernel-language-model-service/LICENSE.APACHE2 b/packages/kernel-language-model-service/LICENSE.APACHE2 new file mode 100644 index 0000000000..8194a06aee --- /dev/null +++ b/packages/kernel-language-model-service/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Consensys Software Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/kernel-language-model-service/LICENSE.MIT b/packages/kernel-language-model-service/LICENSE.MIT new file mode 100644 index 0000000000..658c855eb8 --- /dev/null +++ b/packages/kernel-language-model-service/LICENSE.MIT @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 Consensys Software Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/kernel-language-model-service/README.md b/packages/kernel-language-model-service/README.md index d5843f5702..f204880edf 100644 --- a/packages/kernel-language-model-service/README.md +++ b/packages/kernel-language-model-service/README.md @@ -1,7 +1,15 @@ -# `@ocap/kernel-language-model-service` +# `@metamask/kernel-language-model-service` A package providing language model service implementations for the ocap kernel. This package defines interfaces and implementations for integrating various language model providers (like Ollama) into the kernel's object capability system. +## Installation + +`yarn add @metamask/kernel-language-model-service` + +or + +`npm install @metamask/kernel-language-model-service` + ## Overview This package provides: @@ -23,18 +31,18 @@ All model instances are hardened using `harden()` from `@endo/ses` for security. ## Installation -`yarn add @ocap/kernel-language-model-service` +`yarn add @metamask/kernel-language-model-service` or -`npm install @ocap/kernel-language-model-service` +`npm install @metamask/kernel-language-model-service` ## Usage ### Basic Ollama Integration ```typescript -import { OllamaNodejsService } from '@ocap/kernel-language-model-service/ollama/nodejs'; +import { OllamaNodejsService } from '@metamask/kernel-language-model-service/ollama/nodejs'; // Create a service instance with required endowments const service = new OllamaNodejsService({ @@ -65,7 +73,7 @@ await model.unload(); For enhanced security, you can use the host-restricted fetch utility: ```typescript -import { makeHostRestrictedFetch } from '@ocap/kernel-language-model-service/ollama/fetch'; +import { makeHostRestrictedFetch } from '@metamask/kernel-language-model-service/ollama/fetch'; const restrictedFetch = makeHostRestrictedFetch( ['localhost:11434'], diff --git a/packages/kernel-language-model-service/package.json b/packages/kernel-language-model-service/package.json index efea3c81c8..21ec6c963e 100644 --- a/packages/kernel-language-model-service/package.json +++ b/packages/kernel-language-model-service/package.json @@ -1,8 +1,12 @@ { - "name": "@ocap/kernel-language-model-service", + "name": "@metamask/kernel-language-model-service", "version": "0.0.0", - "private": true, "description": "A place for implementations providing language model services to the ocap kernel", + "keywords": [ + "MetaMask", + "object capabilities", + "ocap" + ], "homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/kernel-language-model-service#readme", "bugs": { "url": "https://github.com/MetaMask/ocap-kernel/issues" @@ -11,7 +15,9 @@ "type": "git", "url": "https://github.com/MetaMask/ocap-kernel.git" }, + "license": "(MIT OR Apache-2.0)", "type": "module", + "sideEffects": false, "exports": { ".": { "import": { @@ -65,13 +71,17 @@ }, "./package.json": "./package.json" }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", "files": [ "dist/" ], "scripts": { "build": "ts-bridge --project tsconfig.build.json --no-references --clean", "build:docs": "typedoc", - "changelog:validate": "../../scripts/validate-changelog.sh @ocap/kernel-language-model-service", + "changelog:update": "../../scripts/update-changelog.sh @metamask/kernel-language-model-service", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kernel-language-model-service", "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck --quiet", @@ -129,5 +139,9 @@ "@metamask/superstruct": "^3.2.1", "ollama": "^0.5.16", "ses": "^1.14.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" } } diff --git a/packages/kernel-test-local/package.json b/packages/kernel-test-local/package.json index ecbec7351c..7f26933ab8 100644 --- a/packages/kernel-test-local/package.json +++ b/packages/kernel-test-local/package.json @@ -32,13 +32,13 @@ }, "dependencies": { "@endo/eventual-send": "^1.3.4", + "@metamask/kernel-language-model-service": "workspace:^", "@metamask/kernel-node-runtime": "workspace:^", "@metamask/kernel-store": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", "@ocap/kernel-agents": "workspace:^", - "@ocap/kernel-language-model-service": "workspace:^", "@ocap/repo-tools": "workspace:^" }, "devDependencies": { diff --git a/packages/kernel-test-local/src/chat-agent.e2e.test.ts b/packages/kernel-test-local/src/chat-agent.e2e.test.ts index 9a55f4fe8d..c5580dfab6 100644 --- a/packages/kernel-test-local/src/chat-agent.e2e.test.ts +++ b/packages/kernel-test-local/src/chat-agent.e2e.test.ts @@ -1,9 +1,9 @@ import '@ocap/repo-tools/test-utils/mock-endoify'; +import { makeOpenV1NodejsService } from '@metamask/kernel-language-model-service'; import { add } from '@ocap/kernel-agents/capabilities/math'; import { makeChatAgent } from '@ocap/kernel-agents/chat'; import type { BoundChat } from '@ocap/kernel-agents/chat'; -import { makeOpenV1NodejsService } from '@ocap/kernel-language-model-service'; import { fetchMock } from '@ocap/repo-tools/test-utils/fetch-mock'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; diff --git a/packages/kernel-test-local/src/lms-chat.e2e.test.ts b/packages/kernel-test-local/src/lms-chat.e2e.test.ts index 9493e44083..db6c4bdb2b 100644 --- a/packages/kernel-test-local/src/lms-chat.e2e.test.ts +++ b/packages/kernel-test-local/src/lms-chat.e2e.test.ts @@ -1,6 +1,6 @@ import '@metamask/kernel-shims/endoify-node'; -import { makeOpenV1NodejsService } from '@ocap/kernel-language-model-service'; +import { makeOpenV1NodejsService } from '@metamask/kernel-language-model-service'; import { fetchMock } from '@ocap/repo-tools/test-utils/fetch-mock'; import { afterAll, beforeAll, describe, it } from 'vitest'; diff --git a/packages/kernel-test-local/src/lms-chat.test.ts b/packages/kernel-test-local/src/lms-chat.test.ts index cc484268bb..e948498fb0 100644 --- a/packages/kernel-test-local/src/lms-chat.test.ts +++ b/packages/kernel-test-local/src/lms-chat.test.ts @@ -1,7 +1,7 @@ import '@metamask/kernel-shims/endoify-node'; -import { makeOpenV1NodejsService } from '@ocap/kernel-language-model-service'; -import { makeMockOpenV1Fetch } from '@ocap/kernel-language-model-service/test-utils'; +import { makeOpenV1NodejsService } from '@metamask/kernel-language-model-service'; +import { makeMockOpenV1Fetch } from '@metamask/kernel-language-model-service/test-utils'; import { describe, it } from 'vitest'; import { runLmsChatKernelTest } from './lms-chat.ts'; diff --git a/packages/kernel-test-local/src/lms-chat.ts b/packages/kernel-test-local/src/lms-chat.ts index bf76cf78e4..57d5c3570a 100644 --- a/packages/kernel-test-local/src/lms-chat.ts +++ b/packages/kernel-test-local/src/lms-chat.ts @@ -1,3 +1,11 @@ +import type { + ChatParams, + ChatResult, +} from '@metamask/kernel-language-model-service'; +import { + LANGUAGE_MODEL_SERVICE_NAME, + makeKernelLanguageModelService, +} from '@metamask/kernel-language-model-service'; import { NodejsPlatformServices } from '@metamask/kernel-node-runtime'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { waitUntilQuiescent } from '@metamask/kernel-utils'; @@ -8,14 +16,6 @@ import { } from '@metamask/logger'; import type { LogEntry } from '@metamask/logger'; import { Kernel } from '@metamask/ocap-kernel'; -import type { - ChatParams, - ChatResult, -} from '@ocap/kernel-language-model-service'; -import { - LANGUAGE_MODEL_SERVICE_NAME, - makeKernelLanguageModelService, -} from '@ocap/kernel-language-model-service'; import { expect } from 'vitest'; import { LMS_CHAT_MODEL } from './constants.ts'; diff --git a/packages/kernel-test-local/src/sample-agent.e2e.test.ts b/packages/kernel-test-local/src/sample-agent.e2e.test.ts index 0402e78b57..c26c3124c4 100644 --- a/packages/kernel-test-local/src/sample-agent.e2e.test.ts +++ b/packages/kernel-test-local/src/sample-agent.e2e.test.ts @@ -1,12 +1,12 @@ import '@ocap/repo-tools/test-utils/mock-endoify'; +import { makeOllamaNodejsKernelService } from '@metamask/kernel-language-model-service/ollama/nodejs'; import { makeConsoleTransport, Logger } from '@metamask/logger'; import type { MakeAgentArgs, Agent } from '@ocap/kernel-agents'; import { getMoonPhase } from '@ocap/kernel-agents/capabilities/examples'; import { count, add, multiply } from '@ocap/kernel-agents/capabilities/math'; import { makeJsonAgent } from '@ocap/kernel-agents/json'; import { makeReplAgent } from '@ocap/kernel-agents-repl'; -import { makeOllamaNodejsKernelService } from '@ocap/kernel-language-model-service/ollama/nodejs'; import { fetchMock } from '@ocap/repo-tools/test-utils/fetch-mock'; import { afterAll, diff --git a/packages/kernel-test-local/src/vats/lms-chat-vat.ts b/packages/kernel-test-local/src/vats/lms-chat-vat.ts index 2c01356e82..e6a096bbf6 100644 --- a/packages/kernel-test-local/src/vats/lms-chat-vat.ts +++ b/packages/kernel-test-local/src/vats/lms-chat-vat.ts @@ -1,8 +1,8 @@ import type { ERef } from '@endo/eventual-send'; +import { makeChatClient } from '@metamask/kernel-language-model-service'; +import type { ChatService } from '@metamask/kernel-language-model-service'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Logger } from '@metamask/logger'; -import { makeChatClient } from '@ocap/kernel-language-model-service'; -import type { ChatService } from '@ocap/kernel-language-model-service'; /** * A vat that uses a kernel language model service to perform a chat completion diff --git a/packages/kernel-test-local/test/suite.test.ts b/packages/kernel-test-local/test/suite.test.ts index 3938565f0c..02d191a025 100644 --- a/packages/kernel-test-local/test/suite.test.ts +++ b/packages/kernel-test-local/test/suite.test.ts @@ -8,7 +8,7 @@ */ import '@ocap/repo-tools/test-utils/mock-endoify'; -import { makeOpenV1NodejsService } from '@ocap/kernel-language-model-service/open-v1/nodejs'; +import { makeOpenV1NodejsService } from '@metamask/kernel-language-model-service/open-v1/nodejs'; import { fetchMock } from '@ocap/repo-tools/test-utils/fetch-mock'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index b0ef8e0dd7..c70194da25 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -54,12 +54,12 @@ "@endo/promise-kit": "^1.1.13", "@libp2p/crypto": "5.1.14", "@libp2p/peer-id": "6.0.5", + "@metamask/kernel-language-model-service": "workspace:^", "@metamask/kernel-node-runtime": "workspace:^", "@metamask/kernel-store": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", - "@ocap/kernel-language-model-service": "workspace:^", "@ocap/nodejs-test-workers": "workspace:^", "@ocap/remote-iterables": "workspace:^" }, diff --git a/packages/kernel-test/src/lms-chat.test.ts b/packages/kernel-test/src/lms-chat.test.ts index c655b61b9d..33d9d38fc0 100644 --- a/packages/kernel-test/src/lms-chat.test.ts +++ b/packages/kernel-test/src/lms-chat.test.ts @@ -1,11 +1,11 @@ -import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; -import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { LANGUAGE_MODEL_SERVICE_NAME, makeKernelLanguageModelService, makeOpenV1NodejsService, -} from '@ocap/kernel-language-model-service'; -import { makeMockOpenV1Fetch } from '@ocap/kernel-language-model-service/test-utils'; +} from '@metamask/kernel-language-model-service'; +import { makeMockOpenV1Fetch } from '@metamask/kernel-language-model-service/test-utils'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { describe, expect, it } from 'vitest'; import { diff --git a/packages/kernel-test/src/lms-sample.test.ts b/packages/kernel-test/src/lms-sample.test.ts index 999f152a96..4eadec602e 100644 --- a/packages/kernel-test/src/lms-sample.test.ts +++ b/packages/kernel-test/src/lms-sample.test.ts @@ -1,11 +1,11 @@ -import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; -import { waitUntilQuiescent } from '@metamask/kernel-utils'; -import type { LogEntry } from '@metamask/logger'; import { LANGUAGE_MODEL_SERVICE_NAME, makeKernelLanguageModelService, -} from '@ocap/kernel-language-model-service'; -import { makeMockSample } from '@ocap/kernel-language-model-service/test-utils'; +} from '@metamask/kernel-language-model-service'; +import { makeMockSample } from '@metamask/kernel-language-model-service/test-utils'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import type { LogEntry } from '@metamask/logger'; import { describe, expect, it, vi } from 'vitest'; import { diff --git a/packages/kernel-test/src/vats/lms-chat-vat.ts b/packages/kernel-test/src/vats/lms-chat-vat.ts index 5831bcb86b..8b65eeea3f 100644 --- a/packages/kernel-test/src/vats/lms-chat-vat.ts +++ b/packages/kernel-test/src/vats/lms-chat-vat.ts @@ -1,7 +1,7 @@ import type { ERef } from '@endo/eventual-send'; +import { makeChatClient } from '@metamask/kernel-language-model-service'; +import type { ChatService } from '@metamask/kernel-language-model-service'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import { makeChatClient } from '@ocap/kernel-language-model-service'; -import type { ChatService } from '@ocap/kernel-language-model-service'; import { unwrapTestLogger } from '../test-powers.ts'; import type { TestPowers } from '../test-powers.ts'; diff --git a/packages/kernel-test/src/vats/lms-sample-vat.ts b/packages/kernel-test/src/vats/lms-sample-vat.ts index e95ed0901a..42d47b5af4 100644 --- a/packages/kernel-test/src/vats/lms-sample-vat.ts +++ b/packages/kernel-test/src/vats/lms-sample-vat.ts @@ -1,7 +1,7 @@ import type { ERef } from '@endo/eventual-send'; +import { makeSampleClient } from '@metamask/kernel-language-model-service'; +import type { SampleService } from '@metamask/kernel-language-model-service'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import { makeSampleClient } from '@ocap/kernel-language-model-service'; -import type { SampleService } from '@ocap/kernel-language-model-service'; import { unwrapTestLogger } from '../test-powers.ts'; import type { TestPowers } from '../test-powers.ts'; diff --git a/packages/llm-bridge/CHANGELOG.md b/packages/llm-bridge/CHANGELOG.md deleted file mode 100644 index 0c82cb1ed6..0000000000 --- a/packages/llm-bridge/CHANGELOG.md +++ /dev/null @@ -1,10 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -[Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/llm-bridge/README.md b/packages/llm-bridge/README.md deleted file mode 100644 index 87ff2c8422..0000000000 --- a/packages/llm-bridge/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# `@ocap/llm-bridge` - -Long-running bridge process that proxies a single Anthropic LLM conversation over a Unix-socket protocol; used by the service matcher (and other LLM-backed demos) so vats can talk to the model without violating SES network restrictions - -## Installation - -`yarn add @ocap/llm-bridge` - -or - -`npm install @ocap/llm-bridge` - -## Contributing - -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/llm-bridge/package.json b/packages/llm-bridge/package.json deleted file mode 100644 index d6674c6fd0..0000000000 --- a/packages/llm-bridge/package.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "name": "@ocap/llm-bridge", - "version": "0.0.0", - "private": true, - "description": "Long-running bridge process that proxies a single LLM conversation between a vat (over a Unix-socket IOChannel) and the openclaw gateway's OpenAI-compatible /v1/chat/completions endpoint. Used so vats can drive an LLM without violating SES network restrictions", - "homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/llm-bridge#readme", - "bugs": { - "url": "https://github.com/MetaMask/ocap-kernel/issues" - }, - "repository": { - "type": "git", - "url": "https://github.com/MetaMask/ocap-kernel.git" - }, - "type": "module", - "exports": { - ".": { - "import": { - "types": "./dist/index.d.mts", - "default": "./dist/index.mjs" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } - }, - "./package.json": "./package.json" - }, - "files": [ - "dist/" - ], - "bin": { - "ocap-llm-bridge": "./dist/index.mjs" - }, - "scripts": { - "build": "ts-bridge --project tsconfig.build.json --no-references --clean", - "build:docs": "typedoc", - "changelog:validate": "../../scripts/validate-changelog.sh @ocap/llm-bridge", - "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", - "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", - "lint:dependencies": "depcheck --quiet", - "lint:eslint": "eslint . --cache", - "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", - "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error", - "publish:preview": "yarn npm publish --tag preview", - "test": "vitest run --config vitest.config.ts", - "test:clean": "yarn test --no-cache --coverage.clean", - "test:dev": "yarn test --mode development", - "test:verbose": "yarn test --reporter verbose", - "test:watch": "vitest --config vitest.config.ts", - "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" - }, - "devDependencies": { - "@arethetypeswrong/cli": "^0.17.4", - "@metamask/auto-changelog": "^5.3.0", - "@metamask/eslint-config": "^15.0.0", - "@metamask/eslint-config-nodejs": "^15.0.0", - "@metamask/eslint-config-typescript": "^15.0.0", - "@ocap/repo-tools": "workspace:^", - "@ts-bridge/cli": "^0.6.3", - "@ts-bridge/shims": "^0.1.1", - "@types/node": "^22.13.1", - "@typescript-eslint/eslint-plugin": "^8.29.0", - "@typescript-eslint/parser": "^8.29.0", - "@typescript-eslint/utils": "^8.29.0", - "@vitest/eslint-plugin": "^1.6.14", - "depcheck": "^1.4.7", - "eslint": "^9.23.0", - "eslint-config-prettier": "^10.1.1", - "eslint-import-resolver-typescript": "^4.3.1", - "eslint-plugin-import-x": "^4.10.0", - "eslint-plugin-jsdoc": "^50.6.9", - "eslint-plugin-n": "^17.17.0", - "eslint-plugin-prettier": "^5.2.6", - "eslint-plugin-promise": "^7.2.1", - "prettier": "^3.5.3", - "rimraf": "^6.0.1", - "turbo": "^2.9.1", - "typedoc": "^0.28.1", - "typescript": "~5.8.2", - "typescript-eslint": "^8.29.0", - "vite": "^8.0.6", - "vitest": "^4.1.3" - }, - "engines": { - "node": ">=22" - }, - "dependencies": { - "@metamask/superstruct": "^3.2.1" - } -} diff --git a/packages/llm-bridge/src/conversation.test.ts b/packages/llm-bridge/src/conversation.test.ts deleted file mode 100644 index 51826e24cb..0000000000 --- a/packages/llm-bridge/src/conversation.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { makeConversation } from './conversation.ts'; -import type { ChatMessage, OpenClawClient } from './openclaw-client.ts'; - -const makeClient = ( - responses: string[], -): { - client: OpenClawClient; - calls: ChatMessage[][]; -} => { - const calls: ChatMessage[][] = []; - let index = 0; - const client: OpenClawClient = { - chat: vi.fn(async (messages: ChatMessage[]) => { - calls.push(messages.map((message) => ({ ...message }))); - const reply = responses[index]; - index += 1; - if (reply === undefined) { - throw new Error( - `mock client ran out of canned replies at call ${index}`, - ); - } - return reply; - }), - }; - return { client, calls }; -}; - -const sampleService = (id: string) => ({ - id, - description: `A service identified as ${id}`, - methods: [{ name: 'doThing', description: `does the thing for ${id}` }], -}); - -describe('Conversation.ingest', () => { - it('appends user/assistant pairs to the persistent history', async () => { - const { client, calls } = makeClient(['ack-1', 'ack-2']); - const conversation = makeConversation(client); - - await conversation.ingest({ - kind: 'ingest', - service: sampleService('svc:0'), - }); - await conversation.ingest({ - kind: 'ingest', - service: sampleService('svc:1'), - }); - - // First call: system + first user message. - expect(calls[0]?.map((message) => message.role)).toStrictEqual([ - 'system', - 'user', - ]); - // Second call: system + first user/assistant pair + second user message. - expect(calls[1]?.map((message) => message.role)).toStrictEqual([ - 'system', - 'user', - 'assistant', - 'user', - ]); - // The persisted assistant turn carries the model's reply text. - expect(calls[1]?.[2]?.content).toBe('ack-1'); - }); - - it('formats method names and descriptions in the user turn', async () => { - const { client, calls } = makeClient(['ack']); - const conversation = makeConversation(client); - - await conversation.ingest({ - kind: 'ingest', - service: sampleService('svc:9'), - }); - - const userMessage = calls[0]?.find((message) => message.role === 'user'); - expect(userMessage?.content).toContain('Register service svc:9'); - expect(userMessage?.content).toContain('A service identified as svc:9'); - expect(userMessage?.content).toContain( - '- doThing: does the thing for svc:9', - ); - }); -}); - -describe('Conversation.query', () => { - it('parses a clean JSON-array reply', async () => { - const { client } = makeClient([ - JSON.stringify([ - { id: 'svc:0', rationale: 'best fit' }, - { id: 'svc:1', rationale: 'second' }, - ]), - ]); - const conversation = makeConversation(client); - - const matches = await conversation.query('something'); - - expect(matches).toStrictEqual([ - { id: 'svc:0', rationale: 'best fit' }, - { id: 'svc:1', rationale: 'second' }, - ]); - }); - - it('parses a reply wrapped in a ```json code fence', async () => { - const { client } = makeClient([ - '```json\n[{"id":"svc:0","rationale":"x"}]\n```', - ]); - const conversation = makeConversation(client); - const matches = await conversation.query('q'); - expect(matches).toStrictEqual([{ id: 'svc:0', rationale: 'x' }]); - }); - - it('returns an empty list when the LLM emits []', async () => { - const { client } = makeClient(['[]']); - const conversation = makeConversation(client); - expect(await conversation.query('q')).toStrictEqual([]); - }); - - it('throws when the reply is not parseable JSON', async () => { - const { client } = makeClient(['nope']); - const conversation = makeConversation(client); - await expect(conversation.query('q')).rejects.toThrow( - /not parseable JSON/u, - ); - }); - - it('throws when the reply is not a JSON array', async () => { - const { client } = makeClient(['{"foo":"bar"}']); - const conversation = makeConversation(client); - await expect(conversation.query('q')).rejects.toThrow(/not a JSON array/u); - }); - - it('throws when an array entry is missing id or rationale', async () => { - const { client } = makeClient(['[{"id":"svc:0"}]']); - const conversation = makeConversation(client); - await expect(conversation.query('q')).rejects.toThrow( - /string id\/rationale/u, - ); - }); - - it('does not accumulate query traffic into the persistent history', async () => { - const { client, calls } = makeClient(['ack', '[]', '[]']); - const conversation = makeConversation(client); - - await conversation.ingest({ - kind: 'ingest', - service: sampleService('svc:0'), - }); - await conversation.query('first query'); - await conversation.query('second query'); - - // Three chat calls: ingest, query, query. - expect(calls).toHaveLength(3); - // After the ingest, persistent history is system+user+assistant (3 turns). - // The first query should send 4 messages (those 3 plus its ephemeral user). - // The second query must send the same 4 — proving the first query's - // user/assistant pair was discarded rather than carried forward. - expect(calls[1]).toHaveLength(4); - expect(calls[2]).toHaveLength(4); - // And the user turn for the second query must contain "second query", - // not "first query". - expect(calls[2]?.[3]?.content).toContain('second query'); - expect(calls[2]?.[3]?.content).not.toContain('first query'); - }); -}); diff --git a/packages/llm-bridge/src/conversation.ts b/packages/llm-bridge/src/conversation.ts deleted file mode 100644 index 8a810c0aaa..0000000000 --- a/packages/llm-bridge/src/conversation.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Conversation manager that the bridge interposes between the matcher - * vat and the LLM gateway. - * - * The bridge owns the conversation history. Service registrations are - * appended to a *persistent* history (each as a user/assistant pair). - * Queries are non-accumulating: each query temporarily appends one - * user turn to a snapshot of the persistent history, gets a reply, - * parses the reply as JSON, and discards both before the next query. - * That way query traffic doesn't pollute the matcher's view of the - * registry, and persistent-history growth is bounded by the - * registration rate rather than the consumer-query rate. - */ - -import type { ChatMessage, OpenClawClient } from './openclaw-client.ts'; -import type { IngestRequest, MatchEntry } from './protocol.ts'; - -const SYSTEM_PROMPT = `You are a service-discovery matcher. You maintain a registry of services and rank candidates against natural-language queries. - -You will receive "Register service" messages, each describing a single service: an opaque ID, a one-sentence description, and a list of method names with optional descriptions. Acknowledge each registration with a short confirmation; you do not need to elaborate. - -You will then receive "Query" messages asking which registered services match a given user intent. For each query, reply with a JSON array AND NOTHING ELSE — no prose, no commentary, no markdown code fences. Each array element must be an object of the form {"id":"","rationale":""}. Order best-first. If no service matches, reply []. Never invent IDs you were not told about.`; - -export type Conversation = { - /** - * Append a service registration to the persistent history. - * - * @param request - The ingest request from the matcher vat. - */ - ingest(request: IngestRequest): Promise; - - /** - * Send a free-text query and parse the LLM's JSON reply into - * structured matches. Does not mutate persistent history. - * - * @param query - The query text from the consumer. - * @returns Parsed match entries, ranked best-first. - */ - query(query: string): Promise; -}; - -/** - * Build a {@link Conversation} backed by an {@link OpenClawClient}. - * - * @param client - HTTP client to use for chat completions. - * @returns A new conversation manager. - */ -export function makeConversation(client: OpenClawClient): Conversation { - const persistent: ChatMessage[] = [ - { role: 'system', content: SYSTEM_PROMPT }, - ]; - - return { - async ingest(request: IngestRequest): Promise { - // Build the user turn locally; only commit both turns once the - // reply has resolved. If `client.chat` rejects (gateway 5xx, - // token error, transient network), the persistent log would - // otherwise be left with an unpaired user turn, and every - // subsequent ingest would send a malformed multi-user-turn - // sequence — degrading ranker behavior and matching the matcher - // vat's own rollback semantics on bridge-ingest failure. - const userTurn: ChatMessage = { - role: 'user', - content: formatIngest(request), - }; - const reply = await client.chat([...persistent, userTurn]); - persistent.push(userTurn, { role: 'assistant', content: reply }); - }, - - async query(query: string): Promise { - // Snapshot the persistent history with one ephemeral query turn. - // Both the user message and the LLM's reply are discarded once - // the query is answered — see the file-level comment for why. - const messages: ChatMessage[] = [ - ...persistent, - { role: 'user', content: formatQuery(query) }, - ]; - const reply = await client.chat(messages); - return parseMatches(reply); - }, - }; -} - -/** - * Format a single ingest request as a multi-line user message. - * - * @param request - The ingest request. - * @returns The user-message content. - */ -function formatIngest(request: IngestRequest): string { - const { service } = request; - const methodLines = - service.methods.length === 0 - ? ' (no methods documented)' - : service.methods - .map( - (method) => - ` - ${method.name}${method.description ? `: ${method.description}` : ''}`, - ) - .join('\n'); - return [ - `Register service ${service.id}:`, - ` Description: ${service.description}`, - ` Methods:`, - methodLines, - ].join('\n'); -} - -/** - * Format the per-query user turn. Repeats the JSON-only output rule - * inline so it can't be lost in a long context window. - * - * @param query - The free-text query. - * @returns The user-message content. - */ -function formatQuery(query: string): string { - return [ - `Query: ${query}`, - '', - 'Reply with a JSON array of {"id","rationale"} objects, ranked best-first, or [] if nothing matches. Reply with JSON ONLY — no prose, no markdown code fences.', - ].join('\n'); -} - -/** - * Parse the LLM's textual reply as a match list. Tolerates an outer - * markdown code fence (some models add one despite instructions) but - * otherwise insists on the exact `[{id, rationale}, ...]` shape. - * - * @param reply - The raw text returned by the LLM. - * @returns The parsed match list. - * @throws If the reply isn't JSON, isn't an array, or any entry is - * missing the `id`/`rationale` strings. - */ -function parseMatches(reply: string): MatchEntry[] { - const trimmed = reply - .trim() - .replace(/^```(?:json)?\s*/iu, '') - .replace(/\s*```$/u, '') - .trim(); - let parsed: unknown; - try { - parsed = JSON.parse(trimmed); - } catch { - throw new Error(`LLM reply was not parseable JSON: ${reply}`); - } - if (!Array.isArray(parsed)) { - throw new Error(`LLM reply was not a JSON array: ${reply}`); - } - const result: MatchEntry[] = []; - for (const entry of parsed) { - if (typeof entry !== 'object' || entry === null) { - throw new Error(`LLM reply array contained a non-object: ${reply}`); - } - const { id } = entry as Record; - const { rationale } = entry as Record; - if (typeof id !== 'string' || typeof rationale !== 'string') { - throw new Error( - `LLM reply array entry missing string id/rationale: ${reply}`, - ); - } - result.push({ id, rationale }); - } - return result; -} diff --git a/packages/llm-bridge/src/index.ts b/packages/llm-bridge/src/index.ts deleted file mode 100644 index 87272e0ea8..0000000000 --- a/packages/llm-bridge/src/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * `ocap-llm-bridge` entry point. - * - * Reads the Unix-socket path from the first positional argument or the - * `LLM_BRIDGE_SOCKET` env var, plus gateway URL, bearer token, and - * agent model from `OPENCLAW_GATEWAY_URL` / `OPENCLAW_GATEWAY_TOKEN` / - * `OPENCLAW_AGENT_MODEL`, then runs the bridge until the kernel-side - * socket closes (or the process is signalled). - */ - -import { makeConversation } from './conversation.ts'; -import { makeOpenClawClient } from './openclaw-client.ts'; -import { runBridge } from './run-bridge.ts'; - -export { makeConversation } from './conversation.ts'; -export { makeOpenClawClient } from './openclaw-client.ts'; -export { runBridge } from './run-bridge.ts'; -export type { - ChatMessage, - ChatRole, - OpenClawClient, - OpenClawClientConfig, -} from './openclaw-client.ts'; -export type { Conversation } from './conversation.ts'; -export type { - IngestRequest, - MatchEntry, - MatchesReply, - MethodDigest, - QueryRequest, - Reply, - Request, - ServiceDigest, -} from './protocol.ts'; - -const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:18789'; -const DEFAULT_AGENT_MODEL = 'openclaw'; - -if (import.meta.url === `file://${process.argv[1]}`) { - // Treated as a script invocation. - main().catch((error: unknown) => { - const message = - error instanceof Error ? (error.stack ?? error.message) : String(error); - // eslint-disable-next-line no-console - console.error(`[llm-bridge] fatal: ${message}`); - process.exitCode = 1; - }); -} - -/** - * Main entry point used when the package is invoked as a CLI. - */ -async function main(): Promise { - const socketPath = - // eslint-disable-next-line n/no-process-env - process.env.LLM_BRIDGE_SOCKET ?? process.argv[2]; - if (!socketPath) { - throw new Error( - 'expected the kernel IO socket path as the first positional argument or in $LLM_BRIDGE_SOCKET', - ); - } - // eslint-disable-next-line n/no-process-env - const token = process.env.OPENCLAW_GATEWAY_TOKEN; - if (!token) { - throw new Error( - 'expected the openclaw gateway bearer token in $OPENCLAW_GATEWAY_TOKEN', - ); - } - const baseUrl = - // eslint-disable-next-line n/no-process-env - process.env.OPENCLAW_GATEWAY_URL ?? DEFAULT_GATEWAY_URL; - const model = - // eslint-disable-next-line n/no-process-env - process.env.OPENCLAW_AGENT_MODEL ?? DEFAULT_AGENT_MODEL; - - const log = (message: string): void => { - // eslint-disable-next-line no-console - console.error(`[llm-bridge] ${message}`); - }; - const client = makeOpenClawClient({ baseUrl, token, model, log }); - const conversation = makeConversation(client); - - log(`starting; gateway=${baseUrl} model=${model} socket=${socketPath}`); - - await runBridge({ socketPath, conversation, log }); -} diff --git a/packages/llm-bridge/src/openclaw-client.test.ts b/packages/llm-bridge/src/openclaw-client.test.ts deleted file mode 100644 index 14571d2a31..0000000000 --- a/packages/llm-bridge/src/openclaw-client.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { makeOpenClawClient } from './openclaw-client.ts'; - -describe('makeOpenClawClient.chat', () => { - let fetchSpy: ReturnType; - - beforeEach(() => { - fetchSpy = vi.spyOn(globalThis, 'fetch'); - }); - - afterEach(() => { - fetchSpy.mockRestore(); - }); - - const okResponse = (content: string): Response => - new Response(JSON.stringify({ choices: [{ message: { content } }] }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }); - - it('posts to /v1/chat/completions on the base URL', async () => { - fetchSpy.mockResolvedValue(okResponse('hi')); - const client = makeOpenClawClient({ - baseUrl: 'http://example.test:18789', - token: 'tok', - model: 'openclaw', - }); - - await client.chat([{ role: 'user', content: 'hi' }]); - - expect(fetchSpy).toHaveBeenCalledWith( - 'http://example.test:18789/v1/chat/completions', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - authorization: 'Bearer tok', - 'content-type': 'application/json', - }), - }), - ); - }); - - it('strips a trailing slash from baseUrl when constructing the URL', async () => { - fetchSpy.mockResolvedValue(okResponse('hi')); - const client = makeOpenClawClient({ - baseUrl: 'http://example.test:18789/', - token: 'tok', - model: 'openclaw', - }); - - await client.chat([{ role: 'user', content: 'hi' }]); - - expect(fetchSpy).toHaveBeenCalledWith( - 'http://example.test:18789/v1/chat/completions', - expect.anything(), - ); - }); - - it('returns choices[0].message.content from the response body', async () => { - fetchSpy.mockResolvedValue(okResponse('the reply')); - const client = makeOpenClawClient({ - baseUrl: 'http://example.test:18789', - token: 'tok', - model: 'openclaw', - }); - expect(await client.chat([{ role: 'user', content: 'q' }])).toBe( - 'the reply', - ); - }); - - it('throws when the gateway responds non-2xx', async () => { - fetchSpy.mockResolvedValue(new Response('forbidden', { status: 401 })); - const client = makeOpenClawClient({ - baseUrl: 'http://example.test:18789', - token: 'tok', - model: 'openclaw', - }); - await expect(client.chat([{ role: 'user', content: 'q' }])).rejects.toThrow( - /HTTP 401: forbidden/u, - ); - }); - - it('throws when the response body has no choices[0].message.content', async () => { - fetchSpy.mockResolvedValue( - new Response(JSON.stringify({ choices: [] }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ); - const client = makeOpenClawClient({ - baseUrl: 'http://example.test:18789', - token: 'tok', - model: 'openclaw', - }); - await expect(client.chat([{ role: 'user', content: 'q' }])).rejects.toThrow( - /missing choices\[0\]\.message\.content/u, - ); - }); - - it('logs the request body and the parsed response when a logger is supplied', async () => { - fetchSpy.mockResolvedValue(okResponse('hello back')); - const lines: string[] = []; - const client = makeOpenClawClient({ - baseUrl: 'http://example.test:18789', - token: 'tok', - model: 'openclaw', - log: (line: string) => lines.push(line), - }); - - await client.chat([{ role: 'user', content: 'hi' }]); - - const requestLine = lines.find((line) => - line.startsWith('→ chat-completions request:'), - ); - const replyLine = lines.find((line) => - line.startsWith('← chat-completions reply:'), - ); - expect(requestLine).toBeDefined(); - expect(requestLine).toContain('"role":"user"'); - expect(requestLine).toContain('"content":"hi"'); - expect(replyLine).toBeDefined(); - expect(replyLine).toContain('"content":"hello back"'); - }); - - it('logs an error line on non-2xx responses', async () => { - fetchSpy.mockResolvedValue(new Response('forbidden', { status: 401 })); - const lines: string[] = []; - const client = makeOpenClawClient({ - baseUrl: 'http://example.test:18789', - token: 'tok', - model: 'openclaw', - log: (line: string) => lines.push(line), - }); - - await expect(client.chat([{ role: 'user', content: 'q' }])).rejects.toThrow( - /HTTP 401/u, - ); - expect( - lines.some((line) => - line.startsWith('← chat-completions error HTTP 401'), - ), - ).toBe(true); - }); - - it('sends the configured model and the supplied messages in the request body', async () => { - fetchSpy.mockResolvedValue(okResponse('ok')); - const client = makeOpenClawClient({ - baseUrl: 'http://example.test:18789', - token: 'tok', - model: 'openclaw/custom-agent', - }); - - await client.chat([ - { role: 'system', content: 'sys' }, - { role: 'user', content: 'u' }, - ]); - - const [, init] = fetchSpy.mock.calls[0] ?? []; - const body = JSON.parse(String(init?.body)); - expect(body).toStrictEqual({ - model: 'openclaw/custom-agent', - messages: [ - { role: 'system', content: 'sys' }, - { role: 'user', content: 'u' }, - ], - }); - }); -}); diff --git a/packages/llm-bridge/src/openclaw-client.ts b/packages/llm-bridge/src/openclaw-client.ts deleted file mode 100644 index da176e9ba0..0000000000 --- a/packages/llm-bridge/src/openclaw-client.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Thin HTTP client for openclaw's OpenAI-compatible - * `POST /v1/chat/completions` endpoint. We don't pull in the OpenAI - * SDK or any provider SDK because the gateway already abstracts that - * away — it just speaks the OpenAI wire shape, and our needs are tiny. - */ - -export type ChatRole = 'system' | 'user' | 'assistant'; - -export type ChatMessage = { - role: ChatRole; - content: string; -}; - -export type OpenClawClientConfig = { - /** Base URL of the openclaw gateway, e.g. `http://127.0.0.1:18789`. */ - baseUrl: string; - /** Bearer token matching `gateway.auth.token` in the gateway config. */ - token: string; - /** - * `model` value to send. Per openclaw's docs this is treated as an - * "agent target," not a raw provider model id: `openclaw` resolves - * to the configured default agent; `openclaw/` pins a - * specific one. - */ - model: string; - /** - * Optional logger. When set, the client emits one - * "→ chat-completions request" line and one "← chat-completions reply" - * line per call, each containing the corresponding JSON-encoded body. - * Default: silent. - */ - log?: (message: string) => void; -}; - -export type OpenClawClient = { - /** - * POST the supplied messages to chat/completions and return the - * assistant's textual reply. Throws on non-2xx responses or unexpected - * payload shapes. - * - * @param messages - The full chat history to send. - * @returns The assistant's reply text. - */ - chat(messages: ChatMessage[]): Promise; -}; - -type ChatCompletionsResponse = { - choices?: { message?: { content?: unknown } }[]; -}; - -/** - * Build an {@link OpenClawClient} bound to a particular gateway. - * - * @param config - Gateway URL, bearer token, and agent model. - * @returns A client with a single `chat()` method. - */ -export function makeOpenClawClient( - config: OpenClawClientConfig, -): OpenClawClient { - const url = `${config.baseUrl.replace(/\/$/u, '')}/v1/chat/completions`; - const log = config.log ?? ((): void => undefined); - return { - async chat(messages: ChatMessage[]): Promise { - const requestBody = { model: config.model, messages }; - log(`→ chat-completions request: ${JSON.stringify(requestBody)}`); - const response = await fetch(url, { - method: 'POST', - headers: { - 'content-type': 'application/json', - authorization: `Bearer ${config.token}`, - }, - body: JSON.stringify(requestBody), - }); - if (!response.ok) { - const body = await response.text().catch(() => ''); - log(`← chat-completions error HTTP ${response.status}: ${body}`); - throw new Error( - `openclaw gateway returned HTTP ${response.status}: ${body}`, - ); - } - const parsed = (await response.json()) as ChatCompletionsResponse; - log(`← chat-completions reply: ${JSON.stringify(parsed)}`); - const content = parsed.choices?.[0]?.message?.content; - if (typeof content !== 'string') { - throw new Error( - `openclaw response missing choices[0].message.content: ${JSON.stringify(parsed)}`, - ); - } - return content; - }, - }; -} diff --git a/packages/llm-bridge/src/protocol.ts b/packages/llm-bridge/src/protocol.ts deleted file mode 100644 index 0ccc97293a..0000000000 --- a/packages/llm-bridge/src/protocol.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Wire protocol for the LLM bridge. - * - * The bridge talks to its caller (the matcher vat, in this codebase) - * over a Unix socket as line-delimited JSON. Two request kinds, three - * possible reply kinds. The bridge owns conversation state; the caller - * just sends digests of services as they register and free-text queries - * as consumers ask for matches. - */ - -import { - array, - exactOptional, - literal, - object, - string, - union, -} from '@metamask/superstruct'; -import type { Infer } from '@metamask/superstruct'; - -/** - * Per-method digest sent over the wire. The full `MethodSpec` type from - * `@metamask/service-discovery-types` carries a recursive parameter/return - * type tree we don't need the LLM to see. - */ -export const MethodDigestStruct = object({ - name: string(), - description: exactOptional(string()), -}); -export type MethodDigest = Infer; - -/** - * Compact projection of a `ServiceDescription` that the bridge actually - * uses in its prompts. The caller supplies the opaque `id` (which the - * LLM will cite back in its query replies). - */ -export const ServiceDigestStruct = object({ - id: string(), - description: string(), - methods: array(MethodDigestStruct), -}); -export type ServiceDigest = Infer; - -export const IngestRequestStruct = object({ - kind: literal('ingest'), - service: ServiceDigestStruct, -}); -export type IngestRequest = Infer; - -export const QueryRequestStruct = object({ - kind: literal('query'), - query: string(), -}); -export type QueryRequest = Infer; - -export const RequestStruct = union([IngestRequestStruct, QueryRequestStruct]); -export type Request = Infer; - -export const IngestedReplyStruct = object({ - kind: literal('ingested'), -}); -export type IngestedReply = Infer; - -export const MatchEntryStruct = object({ - id: string(), - rationale: string(), -}); -export type MatchEntry = Infer; - -export const MatchesReplyStruct = object({ - kind: literal('matches'), - matches: array(MatchEntryStruct), -}); -export type MatchesReply = Infer; - -export const ErrorReplyStruct = object({ - kind: literal('error'), - message: string(), -}); -export type ErrorReply = Infer; - -export const ReplyStruct = union([ - IngestedReplyStruct, - MatchesReplyStruct, - ErrorReplyStruct, -]); -export type Reply = Infer; diff --git a/packages/llm-bridge/src/run-bridge.ts b/packages/llm-bridge/src/run-bridge.ts deleted file mode 100644 index 6201641b72..0000000000 --- a/packages/llm-bridge/src/run-bridge.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Connect to a Unix socket exposing a kernel `IOChannel`, read - * line-delimited JSON requests, dispatch each to a {@link Conversation}, - * and write the JSON reply back. Loops until the socket closes or an - * unrecoverable error occurs. - * - * The matching IOChannel implementation in - * `packages/kernel-node-runtime/src/io/socket-channel.ts` listens on - * the socket as a server and accepts one connection at a time, so the - * bridge plays the client role here. - */ - -import { is } from '@metamask/superstruct'; -import { createConnection } from 'node:net'; -import type { Socket } from 'node:net'; - -import type { Conversation } from './conversation.ts'; -import { RequestStruct } from './protocol.ts'; -import type { Reply } from './protocol.ts'; - -export type RunBridgeOptions = { - /** Filesystem path of the Unix socket the kernel listens on. */ - socketPath: string; - /** Conversation manager that handles ingest/query semantics. */ - conversation: Conversation; - /** Optional logger; defaults to silent. */ - log?: (message: string) => void; - /** - * Delay between connect retries while the kernel is still bringing - * the socket up. Default 500ms. - */ - retryDelayMs?: number; - /** - * Maximum connect attempts before giving up. Default 60 (so the - * default schedule waits up to 30 seconds total). - */ - maxRetries?: number; -}; - -/** - * Connect to the kernel's IOChannel socket and process messages until - * the connection ends. - * - * @param options - Bridge options. - */ -export async function runBridge(options: RunBridgeOptions): Promise { - const { - socketPath, - conversation, - log = () => undefined, - retryDelayMs = 500, - maxRetries = 60, - } = options; - - const socket = await connectWithRetry({ - socketPath, - retryDelayMs, - maxRetries, - log, - }); - log(`connected to ${socketPath}`); - - let buffer = ''; - socket.setEncoding('utf8'); - for await (const chunk of socket) { - buffer += chunk; - let newlineIndex = buffer.indexOf('\n'); - while (newlineIndex !== -1) { - const line = buffer.slice(0, newlineIndex); - buffer = buffer.slice(newlineIndex + 1); - newlineIndex = buffer.indexOf('\n'); - if (line.length === 0) { - continue; - } - const reply = await handleLine(line, conversation, log); - socket.write(`${JSON.stringify(reply)}\n`); - } - } - log('connection closed'); -} - -/** - * Connect to the socket, retrying with a fixed delay if it isn't ready - * yet (the kernel may still be bringing the subcluster up). - * - * @param options - Connection options. - * @param options.socketPath - Filesystem path to the socket. - * @param options.retryDelayMs - Delay between attempts in ms. - * @param options.maxRetries - Maximum number of attempts. - * @param options.log - Logger. - * @returns The connected socket. - */ -async function connectWithRetry(options: { - socketPath: string; - retryDelayMs: number; - maxRetries: number; - log: (message: string) => void; -}): Promise { - const { socketPath, retryDelayMs, maxRetries, log } = options; - let lastError: unknown; - for (let attempt = 0; attempt < maxRetries; attempt += 1) { - try { - return await connectOnce(socketPath); - } catch (error) { - lastError = error; - if (attempt === 0) { - log(`waiting for ${socketPath} to become available...`); - } - await new Promise((resolve) => { - setTimeout(resolve, retryDelayMs); - }); - } - } - const reason = - lastError instanceof Error ? lastError.message : String(lastError); - throw new Error( - `could not connect to ${socketPath} after ${maxRetries} attempts: ${reason}`, - ); -} - -/** - * Single connect attempt; resolves once the socket fires `connect`. - * - * @param socketPath - Path to the Unix socket. - * @returns The connected socket. - */ -async function connectOnce(socketPath: string): Promise { - return await new Promise((resolve, reject) => { - const socket = createConnection(socketPath); - const onConnect = (): void => { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - socket.removeListener('error', onError); - resolve(socket); - }; - const onError = (error: Error): void => { - socket.removeListener('connect', onConnect); - reject(error); - }; - socket.once('error', onError); - socket.once('connect', onConnect); - }); -} - -/** - * Parse one line of input, dispatch to the conversation, and produce - * the reply object. Emits a one-line trace per request and per reply - * so the operator can see the round-trip without grepping the - * gateway's HTTP traffic. - * - * @param line - Raw JSON-encoded request line. - * @param conversation - Conversation manager. - * @param log - Logger. - * @returns The reply object to write back to the kernel. - */ -async function handleLine( - line: string, - conversation: Conversation, - log: (message: string) => void, -): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - log(`<- error: could not parse JSON: ${line}`); - return { kind: 'error', message: `could not parse JSON: ${line}` }; - } - if (!is(parsed, RequestStruct)) { - log(`<- error: unrecognized request shape: ${line}`); - return { kind: 'error', message: `unrecognized request shape: ${line}` }; - } - log(`-> ${parsed.kind}: ${JSON.stringify(parsed)}`); - try { - if (parsed.kind === 'ingest') { - await conversation.ingest(parsed); - log(`<- ingested (${parsed.service.id})`); - return { kind: 'ingested' }; - } - const matches = await conversation.query(parsed.query); - log(`<- matches: ${JSON.stringify(matches)}`); - return { kind: 'matches', matches }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log(`<- error handling ${parsed.kind}: ${message}`); - return { kind: 'error', message }; - } -} diff --git a/packages/llm-bridge/tsconfig.build.json b/packages/llm-bridge/tsconfig.build.json deleted file mode 100644 index e630b81cf0..0000000000 --- a/packages/llm-bridge/tsconfig.build.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.packages.build.json", - "compilerOptions": { - "baseUrl": "./", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": "./src", - "types": ["node"] - }, - "references": [], - "files": [], - "include": ["./src"] -} diff --git a/packages/llm-bridge/tsconfig.json b/packages/llm-bridge/tsconfig.json deleted file mode 100644 index bca10d3737..0000000000 --- a/packages/llm-bridge/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.packages.json", - "compilerOptions": { - "baseUrl": "./", - "lib": ["ES2022"], - "types": ["node", "vitest"] - }, - "references": [{ "path": "../repo-tools" }], - "include": [ - "../../vitest.config.ts", - "./src", - "./vite.config.ts", - "./vitest.config.ts" - ] -} diff --git a/packages/llm-bridge/typedoc.json b/packages/llm-bridge/typedoc.json deleted file mode 100644 index f8eb78ae1a..0000000000 --- a/packages/llm-bridge/typedoc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "entryPoints": [], - "excludePrivate": true, - "hideGenerator": true, - "out": "docs", - "tsconfig": "./tsconfig.build.json", - "projectDocuments": ["documents/*.md"] -} diff --git a/packages/llm-bridge/vitest.config.ts b/packages/llm-bridge/vitest.config.ts deleted file mode 100644 index 30673a5d8a..0000000000 --- a/packages/llm-bridge/vitest.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { mergeConfig } from '@ocap/repo-tools/vitest-config'; -import { defineConfig, defineProject } from 'vitest/config'; - -import defaultConfig from '../../vitest.config.ts'; - -export default defineConfig((args) => { - return mergeConfig( - args, - defaultConfig, - defineProject({ - test: { - name: 'llm-bridge', - }, - }), - ); -}); diff --git a/packages/service-matcher/CHANGELOG.md b/packages/service-matcher/CHANGELOG.md index 0c82cb1ed6..dcdf48cf36 100644 --- a/packages/service-matcher/CHANGELOG.md +++ b/packages/service-matcher/CHANGELOG.md @@ -7,4 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** The matcher vat ranks via the daemon's `languageModelService` kernel service instead of the external `@ocap/llm-bridge` process; ranking is stateless (each `findServices` call carries the full current registry in one chat-completion request) and registrations no longer involve the LLM ([#955](https://github.com/MetaMask/ocap-kernel/pull/955)) +- **BREAKING:** `makeMatcherClusterConfig` requires a `model` option, passed to the matcher vat as its `model` parameter ([#955](https://github.com/MetaMask/ocap-kernel/pull/955)) +- `start-matcher.sh` provisions the daemon's `llm.json` instead of spawning an llm-bridge process ([#955](https://github.com/MetaMask/ocap-kernel/pull/955)) + [Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/service-matcher/README.md b/packages/service-matcher/README.md index adb1ac916a..21c3c65322 100644 --- a/packages/service-matcher/README.md +++ b/packages/service-matcher/README.md @@ -11,11 +11,19 @@ relay`); `start-matcher.sh` does not depend on it and can be run in either order, picking up the relay's multiaddr via `--relay`, `$OCAP_RELAY_MULTIADDR`, or `$HOME/.libp2p-relay/relay.addr`. +The matcher ranks services with the `languageModelService` kernel +service, which the daemon registers at startup from `llm.json` in its +OCAP home. `start-matcher.sh` writes that config to point at an +openclaw gateway and requires `OPENCLAW_GATEWAY_TOKEN` in its +environment (`OPENCLAW_GATEWAY_URL` and `OPENCLAW_AGENT_MODEL` are +optional overrides). + ```bash # Start the relay in one terminal (writes ~/.libp2p-relay/relay.addr on success): yarn ocap relay # Start the matcher in another. It prints its OCAP URL on stdout: +export OPENCLAW_GATEWAY_TOKEN= MATCHER_OCAP_URL=$(./packages/service-matcher/scripts/start-matcher.sh) echo "Matcher URL: $MATCHER_OCAP_URL" ``` diff --git a/packages/service-matcher/package.json b/packages/service-matcher/package.json index 46033f59c5..464a8fba18 100644 --- a/packages/service-matcher/package.json +++ b/packages/service-matcher/package.json @@ -49,6 +49,7 @@ }, "dependencies": { "@endo/eventual-send": "^1.3.4", + "@metamask/kernel-language-model-service": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/ocap-kernel": "workspace:^", "@metamask/service-discovery-types": "workspace:^" diff --git a/packages/service-matcher/scripts/reset-everything.sh b/packages/service-matcher/scripts/reset-everything.sh index e299df557d..36fb47374e 100755 --- a/packages/service-matcher/scripts/reset-everything.sh +++ b/packages/service-matcher/scripts/reset-everything.sh @@ -31,6 +31,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" OCAP_BIN="$REPO_ROOT/packages/kernel-cli/dist/app.mjs" CONSUMER_HOME="${OCAP_CONSUMER_HOME:-${HOME}/.ocap-consumer}" MATCHER_HOME="${HOME}/.ocap" +# Stale artifacts from the retired llm-bridge architecture; swept up so a +# reset also cleans state left behind by older checkouts. LLM_BRIDGE_PID_PATH="$MATCHER_HOME/matcher-llm-bridge.pid" LLM_BRIDGE_LOG_PATH="$MATCHER_HOME/matcher-llm-bridge.log" LLM_SOCKET_PATH="$MATCHER_HOME/matcher-llm.sock" @@ -65,7 +67,8 @@ node "$OCAP_BIN" --home "$CONSUMER_HOME" daemon stop >/dev/null 2>&1 || true info "Stopping matcher daemon (if running)..." node "$OCAP_BIN" --home "$MATCHER_HOME" daemon stop >/dev/null 2>&1 || true -# Stop the matcher's llm-bridge process (started by start-matcher.sh). +# Stop any llm-bridge process left behind by an older checkout (the +# matcher now ranks via the daemon's languageModelService; no bridge). if [[ -f "$LLM_BRIDGE_PID_PATH" ]]; then BRIDGE_PID="$(tr -d '[:space:]' < "$LLM_BRIDGE_PID_PATH" || true)" if [[ -n "$BRIDGE_PID" ]] && kill -0 "$BRIDGE_PID" 2>/dev/null; then diff --git a/packages/service-matcher/scripts/start-matcher.sh b/packages/service-matcher/scripts/start-matcher.sh index c2a4150c7b..f72aede3af 100755 --- a/packages/service-matcher/scripts/start-matcher.sh +++ b/packages/service-matcher/scripts/start-matcher.sh @@ -14,15 +14,17 @@ # If none of these yields an address the script exits with an error so # the operator can start the relay first (or pass its address directly). # -# The matcher vat now ranks via an LLM-backed bridge process -# (`@ocap/llm-bridge`) connected through a Unix-socket IOChannel. This -# script also starts the bridge, which calls openclaw's OpenAI-compatible -# /v1/chat/completions endpoint. Required env: +# The matcher vat ranks via the `languageModelService` kernel service. +# This script provisions the daemon's LLM config (`llm.json` in the OCAP +# home) to point at openclaw's OpenAI-compatible /v1/chat/completions +# endpoint; the daemon registers the service at startup and reads the +# bearer token from this script's environment. Required env: # OPENCLAW_GATEWAY_TOKEN Bearer token for the openclaw gateway. Must # match `gateway.auth.token` in openclaw config. # OPENCLAW_GATEWAY_URL Optional. Default http://127.0.0.1:18789. # OPENCLAW_AGENT_MODEL Optional. Default "openclaw" (the gateway's -# configured default agent). +# configured default agent). Passed to the +# matcher vat as its `model` parameter. # The matching openclaw config flag must be enabled on the gateway: # gateway.http.endpoints.chatCompletions.enabled = true # @@ -53,8 +55,7 @@ Usage: $0 [--relay MULTIADDR] [--no-build] [--keep-state] \$OCAP_RELAY_MULTIADDR and \$LIBP2P_RELAY_HOME/relay.addr (default \$HOME/.libp2p-relay/relay.addr). - --no-build Skip building/bundling the matcher vat and - building the llm-bridge package. + --no-build Skip building/bundling the matcher vat. --keep-state Do not purge any existing daemon state before launching the matcher subcluster. --help, -h Show this help. @@ -93,7 +94,7 @@ fail() { echo "[start-matcher] ERROR: $*" >&2; exit 1; } # --------------------------------------------------------------------------- if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then - fail "OPENCLAW_GATEWAY_TOKEN must be set (the matcher vat now talks to an LLM bridge that calls the openclaw gateway)." + fail "OPENCLAW_GATEWAY_TOKEN must be set (the daemon's languageModelService calls the openclaw gateway with it)." fi # --------------------------------------------------------------------------- @@ -117,14 +118,10 @@ PKG_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_ROOT="$(cd "$PKG_DIR/../.." && pwd)" OCAP_BIN="$REPO_ROOT/packages/kernel-cli/dist/app.mjs" BUNDLE_FILE="$PKG_DIR/src/matcher-vat/index.bundle" -LLM_BRIDGE_BIN="$REPO_ROOT/packages/llm-bridge/dist/index.mjs" -# Where the matcher vat's `llm` IOService lives, plus bookkeeping files -# for the bridge process this script will spawn. +# Where the daemon reads its LLM config from at startup. OCAP_HOME_DIR="${OCAP_HOME:-${HOME}/.ocap}" -LLM_SOCKET_PATH="$OCAP_HOME_DIR/matcher-llm.sock" -LLM_BRIDGE_PID_PATH="$OCAP_HOME_DIR/matcher-llm-bridge.pid" -LLM_BRIDGE_LOG_PATH="$OCAP_HOME_DIR/matcher-llm-bridge.log" +LLM_CONFIG_PATH="$OCAP_HOME_DIR/llm.json" if [[ ! -f "$OCAP_BIN" ]]; then fail "ocap CLI not found at $OCAP_BIN. Run \`yarn workspace @metamask/kernel-cli build\` first." @@ -137,29 +134,11 @@ fi if $SKIP_BUILD; then info "Skipping build (--no-build)" [[ -f "$BUNDLE_FILE" ]] || fail "Bundle not found at $BUNDLE_FILE. Remove --no-build or build first." - [[ -f "$LLM_BRIDGE_BIN" ]] || fail "llm-bridge build not found at $LLM_BRIDGE_BIN. Remove --no-build or build first." else info "Building service-matcher package..." (cd "$REPO_ROOT" && yarn workspace @ocap/service-matcher build >&2) info "Bundling matcher vat..." (cd "$REPO_ROOT" && yarn workspace @ocap/service-matcher bundle-vat >&2) - info "Building llm-bridge package..." - (cd "$REPO_ROOT" && yarn workspace @ocap/llm-bridge build >&2) -fi - -# --------------------------------------------------------------------------- -# Reap any old llm-bridge process from a previous run -# --------------------------------------------------------------------------- - -if [[ -f "$LLM_BRIDGE_PID_PATH" ]]; then - OLD_PID="$(tr -d '[:space:]' < "$LLM_BRIDGE_PID_PATH" || true)" - if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then - info "Reaping previous llm-bridge (pid $OLD_PID)..." - kill "$OLD_PID" 2>/dev/null || true - sleep 0.5 - kill -KILL "$OLD_PID" 2>/dev/null || true - fi - rm -f "$LLM_BRIDGE_PID_PATH" fi # --------------------------------------------------------------------------- @@ -171,8 +150,27 @@ if $FORCE_RESET; then (cd "$REPO_ROOT" && node "$OCAP_BIN" daemon purge --force >&2) || true fi +# Provision the daemon's LLM config. Written after the purge (which may +# delete the OCAP home) and before `daemon start` (which reads it once, +# at startup). The token itself stays out of the file: the daemon reads +# it from OPENCLAW_GATEWAY_TOKEN, inherited from this script's +# environment via `daemon start` below. +mkdir -p "$OCAP_HOME_DIR" +GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-http://127.0.0.1:18789}" node -e " + const config = { + provider: 'open-v1', + baseUrl: process.env.GATEWAY_URL, + apiKeyEnv: 'OPENCLAW_GATEWAY_TOKEN', + }; + require('fs').writeFileSync(process.argv[1], JSON.stringify(config, null, 2) + '\n'); +" "$LLM_CONFIG_PATH" +info "Wrote LLM config → $LLM_CONFIG_PATH" + info "Starting daemon..." # `daemon start` fails if one is already running; detect and continue. +# NOTE: a pre-existing daemon read (or missed) llm.json at *its* startup; +# if it predates this script's config, restart it (`ocap daemon stop`) +# so the languageModelService gets registered. if ! (cd "$REPO_ROOT" && node "$OCAP_BIN" daemon start >&2); then info "daemon start failed — assuming one is already running" fi @@ -225,18 +223,22 @@ info "Remote comms connected" CONFIG=$(BUNDLE="file://$BUNDLE_FILE" \ RESET="$FORCE_RESET" \ - LLM_SOCKET="$LLM_SOCKET_PATH" \ + MODEL="${OPENCLAW_AGENT_MODEL:-openclaw}" \ node -e " const config = { config: { bootstrap: 'matcher', forceReset: process.env.RESET === 'true', - services: ['ocapURLIssuerService', 'ocapURLRedemptionService'], - io: { - llm: { type: 'socket', path: process.env.LLM_SOCKET } - }, + services: [ + 'ocapURLIssuerService', + 'ocapURLRedemptionService', + 'languageModelService', + ], vats: { - matcher: { bundleSpec: process.env.BUNDLE } + matcher: { + bundleSpec: process.env.BUNDLE, + parameters: { model: process.env.MODEL }, + } } } }; @@ -265,45 +267,5 @@ MATCHER_URL=$(echo "$LAUNCH_RESULT" | node -e " process.stdout.write(url); ") -# --------------------------------------------------------------------------- -# Start the LLM bridge -# -# The matcher subcluster is now running, which means the kernel has -# created the Unix socket at $LLM_SOCKET_PATH. Spawn the bridge as a -# detached background process; it will connect to the socket (with its -# own retry loop) and proxy ingest/query traffic to the openclaw -# gateway. -# --------------------------------------------------------------------------- - -info "Starting llm-bridge..." -mkdir -p "$OCAP_HOME_DIR" -: > "$LLM_BRIDGE_LOG_PATH" - -# Export the socket path so the bridge picks it up. The other env -# vars (OPENCLAW_GATEWAY_TOKEN/_URL/_MODEL) are inherited verbatim -# from this script's environment. -export LLM_BRIDGE_SOCKET="$LLM_SOCKET_PATH" - -# `setsid` (where available) detaches the bridge from this script's -# process group so a `kill` on the script doesn't take the bridge with -# it. macOS doesn't ship setsid by default; fall back to `nohup`. -if command -v setsid >/dev/null 2>&1; then - setsid node "$LLM_BRIDGE_BIN" >>"$LLM_BRIDGE_LOG_PATH" 2>&1 & -else - nohup node "$LLM_BRIDGE_BIN" >>"$LLM_BRIDGE_LOG_PATH" 2>&1 & -fi -LLM_BRIDGE_PID=$! -echo "$LLM_BRIDGE_PID" > "$LLM_BRIDGE_PID_PATH" -info "llm-bridge spawned (pid $LLM_BRIDGE_PID); log → $LLM_BRIDGE_LOG_PATH" - -# Quick liveness check: if the bridge died immediately (e.g. token -# misconfigured at the openclaw side), surface that here rather than -# letting the operator discover it on the first registration attempt. -sleep 0.5 -if ! kill -0 "$LLM_BRIDGE_PID" 2>/dev/null; then - rm -f "$LLM_BRIDGE_PID_PATH" - fail "llm-bridge exited immediately — see $LLM_BRIDGE_LOG_PATH" -fi - info "Matcher ready." echo "$MATCHER_URL" diff --git a/packages/service-matcher/src/cluster-config.test.ts b/packages/service-matcher/src/cluster-config.test.ts index 776cfbad3f..ead75bea3c 100644 --- a/packages/service-matcher/src/cluster-config.test.ts +++ b/packages/service-matcher/src/cluster-config.test.ts @@ -10,25 +10,41 @@ describe('makeMatcherClusterConfig', () => { it('produces a config with the matcher vat as the bootstrap', () => { const config = makeMatcherClusterConfig({ bundleBaseUrl: 'file:///tmp/matcher', + model: 'openclaw', }); expect(config.bootstrap).toBe(MATCHER_VAT_NAME); expect(config.services).toStrictEqual([ 'ocapURLIssuerService', 'ocapURLRedemptionService', + 'languageModelService', ]); expect(config.vats[MATCHER_VAT_NAME]?.bundleSpec).toBe( `file:///tmp/matcher/${MATCHER_BUNDLE_FILENAME}`, ); }); + it('passes the model through as a vat parameter', () => { + const config = makeMatcherClusterConfig({ + bundleBaseUrl: 'x', + model: 'openclaw/ranker', + }); + expect(config.vats[MATCHER_VAT_NAME]?.parameters).toStrictEqual({ + model: 'openclaw/ranker', + }); + }); + it('defaults forceReset to false', () => { - const config = makeMatcherClusterConfig({ bundleBaseUrl: 'x' }); + const config = makeMatcherClusterConfig({ + bundleBaseUrl: 'x', + model: 'openclaw', + }); expect(config.forceReset).toBe(false); }); it('passes forceReset through when set', () => { const config = makeMatcherClusterConfig({ bundleBaseUrl: 'x', + model: 'openclaw', forceReset: true, }); expect(config.forceReset).toBe(true); diff --git a/packages/service-matcher/src/cluster-config.ts b/packages/service-matcher/src/cluster-config.ts index cb47c8dfcb..f5ce17fbfd 100644 --- a/packages/service-matcher/src/cluster-config.ts +++ b/packages/service-matcher/src/cluster-config.ts @@ -25,25 +25,38 @@ export type MatcherBootstrapResult = { /** * Build a `ClusterConfig` for launching the matcher subcluster. * + * The matcher requires the `languageModelService` kernel service for + * ranking, so the daemon hosting this subcluster must have an LLM + * configured (see the kernel CLI's `llm.json`). + * * @param options - Configuration options. * @param options.bundleBaseUrl - Base URL (or filesystem path) where the * matcher vat bundle is reachable. The bundle filename is appended. + * @param options.model - Model name the matcher sends with every ranking + * request. For an openclaw gateway this is an agent target like + * `openclaw` or `openclaw/`. * @param options.forceReset - Whether to reset the subcluster on launch. * Defaults to `false`. * @returns A ClusterConfig ready for `kernel.launchSubcluster(...)`. */ export function makeMatcherClusterConfig(options: { bundleBaseUrl: string; + model: string; forceReset?: boolean; }): ClusterConfig { - const { bundleBaseUrl, forceReset = false } = options; + const { bundleBaseUrl, model, forceReset = false } = options; return { bootstrap: MATCHER_VAT_NAME, forceReset, - services: ['ocapURLIssuerService', 'ocapURLRedemptionService'], + services: [ + 'ocapURLIssuerService', + 'ocapURLRedemptionService', + 'languageModelService', + ], vats: { [MATCHER_VAT_NAME]: { bundleSpec: `${bundleBaseUrl}/${MATCHER_BUNDLE_FILENAME}`, + parameters: { model }, }, }, }; diff --git a/packages/service-matcher/src/matcher-vat/index.test.ts b/packages/service-matcher/src/matcher-vat/index.test.ts index f5d5e52e9a..299b397da4 100644 --- a/packages/service-matcher/src/matcher-vat/index.test.ts +++ b/packages/service-matcher/src/matcher-vat/index.test.ts @@ -1,3 +1,7 @@ +import type { + ChatParams, + ChatResult, +} from '@metamask/kernel-language-model-service'; import type { ContactPoint, ServiceDescription, @@ -13,17 +17,16 @@ vi.mock('@endo/eventual-send', () => ({ E: vi.fn((obj: unknown) => obj), })); -type IOService = { - read: () => Promise; - write: (data: string) => Promise; -}; - type Services = { ocapURLIssuerService: { issue: (obj: unknown) => Promise }; ocapURLRedemptionService: { redeem: (url: string) => Promise }; - llm: IOService; + languageModelService: { + chat: (params: ChatParams) => Promise; + }; }; +const TEST_MODEL = 'test-model'; + const sampleDescription = ( name = 'Signer', contactUrl = 'ocap:abc@peer', @@ -67,91 +70,80 @@ function makeMockContact(options: { } /** - * Build a fake `llm` IOService. Every `write()` call enqueues the - * incoming line and synthesizes a reply via the supplied `replyFor` - * callback; subsequent `read()` calls drain the queued replies in - * order, matching the kernel-side IOChannel's "one line at a time" - * semantics. Defaults to acknowledging every ingest with `{ kind: - * "ingested" }` and answering every query with an empty match list, - * which the per-test setup can override. + * Build a fake `languageModelService`. Each `chat()` call records its + * params and replies with assistant content synthesized by the supplied + * `replyContent` callback (default: an empty match list, `[]`). * * @param options - Mock options. - * @param options.replyFor - Custom reply function. Receives the parsed - * request and returns the reply object the bridge would send back. - * @returns The IOService plus inspection helpers. + * @param options.replyContent - Custom reply function. Receives the + * chat params and returns the assistant message content (or throws to + * simulate a gateway failure). + * @returns The mock service plus inspection helpers. */ -function makeMockLlm( +function makeMockLms( options: { - replyFor?: (request: unknown) => unknown; + replyContent?: (params: ChatParams) => string; } = {}, ) { - const writes: unknown[] = []; - const replyQueue: string[] = []; - const replyFor = - options.replyFor ?? - ((request: unknown) => { - const { kind } = request as { kind?: string }; - if (kind === 'ingest') { - return { kind: 'ingested' }; - } - if (kind === 'query') { - return { kind: 'matches', matches: [] }; - } - return { kind: 'error', message: `unknown request kind: ${kind ?? '?'}` }; - }); - - const write = vi.fn(async (data: string) => { - const parsed: unknown = JSON.parse(data); - writes.push(parsed); - replyQueue.push(JSON.stringify(replyFor(parsed))); - }); - const read = vi.fn(async () => { - const next = replyQueue.shift(); - return next ?? null; + const calls: ChatParams[] = []; + const replyContent = options.replyContent ?? (() => '[]'); + + const chat = vi.fn(async (params: ChatParams): Promise => { + calls.push(params); + return { + id: `chat-${calls.length}`, + model: params.model, + choices: [ + { + index: 0, + message: { role: 'assistant', content: replyContent(params) }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }; }); - const llm: IOService = { read, write }; return { - llm, - writes, - write, - read, + service: { chat }, + chat, + calls, /** - * Inspect the most recently sent request. + * Inspect the most recent chat request. * - * @returns The last parsed request the matcher wrote, or `undefined`. + * @returns The last chat params the matcher sent, or `undefined`. */ - lastRequest: (): unknown => writes[writes.length - 1], + lastParams: (): ChatParams | undefined => calls[calls.length - 1], }; } /** * Build a default services bag whose `issue` returns a deterministic * URL, whose `redeem` resolves a preconfigured contact, and whose - * `llm` IOService can be swapped per-test. + * `languageModelService` can be swapped per-test. * * @param options - Mock options. - * @param options.llm - Override for the llm IOService and inspection - * helpers (default: an LLM that acks ingests and returns empty matches). + * @param options.lms - Override for the language model service mock + * (default: a model that answers every ranking request with `[]`). * @returns The services bag plus helpers to inspect calls. */ function makeMockServices( - options: { llm?: ReturnType } = {}, + options: { lms?: ReturnType } = {}, ) { const issue = vi.fn(async (_obj: unknown) => 'ocap:matcher-url@peer'); let redeemResult: unknown = null; const redeem = vi.fn(async (_url: string) => redeemResult); - const llmMock = options.llm ?? makeMockLlm(); + const lmsMock = options.lms ?? makeMockLms(); const services: Services = { ocapURLIssuerService: { issue }, ocapURLRedemptionService: { redeem }, - llm: llmMock.llm, + languageModelService: lmsMock.service, }; return { services, issue, redeem, - llm: llmMock, + lms: lmsMock, setRedeem: (value: unknown) => { redeemResult = value; }, @@ -229,10 +221,30 @@ describe('matcher vat', () => { let mocks: ReturnType; beforeEach(async () => { - root = buildRootObject(makeFakeVatPowers(), {}, makeFakeBaggage() as never); + root = buildRootObject( + makeFakeVatPowers(), + { model: TEST_MODEL }, + makeFakeBaggage() as never, + ); mocks = makeMockServices(); }); + describe('parameters', () => { + it.each([ + ['missing', {}], + ['empty', { model: '' }], + ['non-string', { model: 42 }], + ])('throws when the model parameter is %s', (_case, parameters) => { + expect(() => + buildRootObject( + makeFakeVatPowers(), + parameters, + makeFakeBaggage() as never, + ), + ).toThrow(/"model" vat parameter is required/u); + }); + }); + describe('bootstrap', () => { it('issues an ocap URL for the public facet and returns it', async () => { const result = await root.bootstrap({}, mocks.services); @@ -244,7 +256,7 @@ describe('matcher vat', () => { await expect( root.bootstrap({}, { ocapURLRedemptionService: mocks.services.ocapURLRedemptionService, - llm: mocks.services.llm, + languageModelService: mocks.services.languageModelService, } as Services), ).rejects.toThrow('ocapURLIssuerService is required'); }); @@ -253,23 +265,23 @@ describe('matcher vat', () => { await expect( root.bootstrap({}, { ocapURLIssuerService: mocks.services.ocapURLIssuerService, - llm: mocks.services.llm, + languageModelService: mocks.services.languageModelService, } as Services), ).rejects.toThrow('ocapURLRedemptionService is required'); }); - it('throws if the llm IOService is missing', async () => { + it('throws if the languageModelService is missing', async () => { await expect( root.bootstrap({}, { ocapURLIssuerService: mocks.services.ocapURLIssuerService, ocapURLRedemptionService: mocks.services.ocapURLRedemptionService, } as Services), - ).rejects.toThrow(/llm IOService is required/u); + ).rejects.toThrow(/languageModelService is required/u); }); }); describe('registerServiceByRef', () => { - it('confirms the token, stores the service, and feeds it to the bridge', async () => { + it('confirms the token and stores the service without calling the LLM', async () => { await root.bootstrap({}, mocks.services); const publicFacet = root.getPublicFacet(); const description = sampleDescription('Foo'); @@ -285,14 +297,9 @@ describe('matcher vat', () => { expect(all).toHaveLength(1); expect(all[0]?.description).toStrictEqual(description); - // Bridge round-trip should have happened: one ingest write, - // one ingest read. - expect(mocks.llm.write).toHaveBeenCalledTimes(1); - expect(mocks.llm.read).toHaveBeenCalledTimes(1); - expect(mocks.llm.lastRequest()).toMatchObject({ - kind: 'ingest', - service: { id: 'svc:0', description: description.description }, - }); + // Registration is purely local; the LLM only sees the registry + // at query time. + expect(mocks.lms.chat).not.toHaveBeenCalled(); }); it('rejects when the provider reports a token mismatch', async () => { @@ -307,7 +314,6 @@ describe('matcher vat', () => { publicFacet.registerServiceByRef(contact, 'wrong-token'), ).rejects.toThrow(/token mismatch/u); expect(root.listAll()).toHaveLength(0); - expect(mocks.llm.write).not.toHaveBeenCalled(); }); it('calls confirmServiceRegistration before getServiceDescription', async () => { @@ -343,11 +349,13 @@ describe('matcher vat', () => { expect(getServiceDescription).not.toHaveBeenCalled(); }); - it('rolls back the local registry when bridge ingest fails', async () => { - const llm = makeMockLlm({ - replyFor: () => ({ kind: 'error', message: 'bridge bad' }), + it('registers successfully even when the LLM is failing', async () => { + const lms = makeMockLms({ + replyContent: () => { + throw new Error('gateway down'); + }, }); - mocks = makeMockServices({ llm }); + mocks = makeMockServices({ lms }); await root.bootstrap({}, mocks.services); const publicFacet = root.getPublicFacet(); const { contact } = makeMockContact({ @@ -355,10 +363,10 @@ describe('matcher vat', () => { expectedToken: 'tok', }); - await expect( - publicFacet.registerServiceByRef(contact, 'tok'), - ).rejects.toThrow(/bridge ingest error: bridge bad/u); - expect(root.listAll()).toHaveLength(0); + // Registration never touches the LLM, so a broken gateway can't + // block providers from registering. + await publicFacet.registerServiceByRef(contact, 'tok'); + expect(root.listAll()).toHaveLength(1); }); }); @@ -521,107 +529,139 @@ describe('matcher vat', () => { }); describe('findServices', () => { - it('asks the bridge and returns whatever services it cites', async () => { - const llm = makeMockLlm({ - replyFor: (request: unknown) => { - const { kind } = request as { kind?: string }; - if (kind === 'ingest') { - return { kind: 'ingested' }; - } - // Cite svc:0 (the only one we'll register) on every query. - return { - kind: 'matches', - matches: [{ id: 'svc:0', rationale: 'because' }], - }; - }, - }); - mocks = makeMockServices({ llm }); - await root.bootstrap({}, mocks.services); + /** + * Register one sample service so ranking has something to cite. + * + * @param description - The service description to register. + * @returns The registered description. + */ + async function registerOne( + description = sampleDescription('Foo'), + ): Promise { const publicFacet = root.getPublicFacet(); - const description = sampleDescription('Foo'); const { contact } = makeMockContact({ description, expectedToken: 't', }); await publicFacet.registerServiceByRef(contact, 't'); + return description; + } - const matches = await publicFacet.findServices({ + it('asks the model and returns whatever services it cites', async () => { + const lms = makeMockLms({ + // Cite svc:0 (the only one we'll register) on every query. + replyContent: () => '[{"id":"svc:0","rationale":"because"}]', + }); + mocks = makeMockServices({ lms }); + await root.bootstrap({}, mocks.services); + const description = await registerOne(); + + const matches = await root.getPublicFacet().findServices({ description: 'whatever', }); expect(matches).toHaveLength(1); expect(matches[0]?.description).toStrictEqual(description); expect(matches[0]?.rationale).toBe('because'); - // Last bridge request was the query (with the user's text). - expect(llm.lastRequest()).toMatchObject({ - kind: 'query', - query: 'whatever', + }); + + it('sends the configured model, the registry digest, and the query', async () => { + const lms = makeMockLms({ + replyContent: () => '[]', }); + mocks = makeMockServices({ lms }); + await root.bootstrap({}, mocks.services); + const description = await registerOne(); + + await root.getPublicFacet().findServices({ description: 'find me foo' }); + + const params = lms.lastParams(); + expect(params?.model).toBe(TEST_MODEL); + expect(params?.messages).toHaveLength(2); + expect(params?.messages[0]?.role).toBe('system'); + const userContent = params?.messages[1]?.content; + expect(userContent).toContain('svc:0'); + expect(userContent).toContain(description.description); + expect(userContent).toContain('find me foo'); }); - it('returns an empty list when the bridge cites no services', async () => { + it('returns an empty list without calling the model when nothing is registered', async () => { await root.bootstrap({}, mocks.services); - const publicFacet = root.getPublicFacet(); - const matches = await publicFacet.findServices({ description: 'q' }); + const matches = await root + .getPublicFacet() + .findServices({ description: 'q' }); expect(matches).toStrictEqual([]); + expect(mocks.lms.chat).not.toHaveBeenCalled(); }); - it('skips ids the bridge cites that do not exist in the registry', async () => { - const llm = makeMockLlm({ - replyFor: (request: unknown) => { - const { kind } = request as { kind?: string }; - if (kind === 'ingest') { - return { kind: 'ingested' }; - } - return { - kind: 'matches', - matches: [ - { id: 'svc:0', rationale: 'real one' }, - { id: 'svc:nonexistent', rationale: 'hallucinated' }, - ], - }; - }, - }); - mocks = makeMockServices({ llm }); + it('returns an empty list when the model cites no services', async () => { await root.bootstrap({}, mocks.services); - const publicFacet = root.getPublicFacet(); - const { contact } = makeMockContact({ - description: sampleDescription(), - expectedToken: 't', + await registerOne(); + const matches = await root + .getPublicFacet() + .findServices({ description: 'q' }); + expect(matches).toStrictEqual([]); + expect(mocks.lms.chat).toHaveBeenCalledTimes(1); + }); + + it('skips ids the model cites that do not exist in the registry', async () => { + const lms = makeMockLms({ + replyContent: () => + '[{"id":"svc:0","rationale":"real one"},' + + '{"id":"svc:nonexistent","rationale":"hallucinated"}]', }); - await publicFacet.registerServiceByRef(contact, 't'); + mocks = makeMockServices({ lms }); + await root.bootstrap({}, mocks.services); + await registerOne(); - const matches = await publicFacet.findServices({ description: 'q' }); + const matches = await root + .getPublicFacet() + .findServices({ description: 'q' }); expect(matches).toHaveLength(1); expect(matches[0]?.rationale).toBe('real one'); }); - it('propagates bridge errors instead of falling back', async () => { - let queryCount = 0; - const llm = makeMockLlm({ - replyFor: (request: unknown) => { - const { kind } = request as { kind?: string }; - if (kind === 'ingest') { - return { kind: 'ingested' }; - } - queryCount += 1; - return { kind: 'error', message: 'gateway sad' }; + it('throws when the model cites only unknown ids', async () => { + const lms = makeMockLms({ + replyContent: () => '[{"id":"svc:999","rationale":"hallucinated"}]', + }); + mocks = makeMockServices({ lms }); + await root.bootstrap({}, mocks.services); + await registerOne(); + + await expect( + root.getPublicFacet().findServices({ description: 'q' }), + ).rejects.toThrow(/cited only unknown ids/u); + }); + + it('propagates LLM errors instead of falling back', async () => { + const lms = makeMockLms({ + replyContent: () => { + throw new Error('gateway sad'); }, }); - mocks = makeMockServices({ llm }); + mocks = makeMockServices({ lms }); await root.bootstrap({}, mocks.services); - const publicFacet = root.getPublicFacet(); - const { contact } = makeMockContact({ - description: sampleDescription(), - expectedToken: 't', + await registerOne(); + + await expect( + root.getPublicFacet().findServices({ description: 'q' }), + ).rejects.toThrow(/gateway sad/u); + expect(lms.chat).toHaveBeenCalledTimes(1); + }); + + it('throws when the model reply is not valid match JSON', async () => { + const lms = makeMockLms({ + replyContent: () => 'Sure! Here are your matches: none.', }); - await publicFacet.registerServiceByRef(contact, 't'); + mocks = makeMockServices({ lms }); + await root.bootstrap({}, mocks.services); + await registerOne(); await expect( - publicFacet.findServices({ description: 'q' }), - ).rejects.toThrow(/bridge query error: gateway sad/u); - expect(queryCount).toBe(1); + root.getPublicFacet().findServices({ description: 'q' }), + ).rejects.toThrow(/not parseable JSON/u); }); }); diff --git a/packages/service-matcher/src/matcher-vat/index.ts b/packages/service-matcher/src/matcher-vat/index.ts index 8a684727f3..d446519555 100644 --- a/packages/service-matcher/src/matcher-vat/index.ts +++ b/packages/service-matcher/src/matcher-vat/index.ts @@ -13,19 +13,23 @@ * matcher restart, providers must re-register. Making the registry * durable is a planned follow-up; doing so brings its own obligations * (eviction of stale registrations when providers disappear, liveness - * detection, and reconciling persisted entries with the bridge's LLM - * context) that need to be designed before that change lands. + * detection) that need to be designed before that change lands. * - * Ranking is delegated to an LLM-backed bridge process via an - * `IOService` endowment named `llm`. On every successful registration - * the matcher feeds the LLM a digest of the service (description + - * method names); on every `findServices` call it asks the LLM to pick - * matches against the user's natural-language query and replies - * accordingly. There is no fallback ranker — bridge errors propagate - * to the caller so problems are visible during development. + * Ranking is delegated to the `languageModelService` kernel service + * (see `@metamask/kernel-language-model-service`), requested via the + * cluster config's `services` list. Ranking is stateless: every + * `findServices` call sends the full current registry plus the query + * in a single chat-completion request, so registrations never involve + * the LLM and there is no model-side context to drift out of sync with + * the registry. There is no fallback ranker — LLM errors propagate to + * the caller so problems are visible during development. */ import { E } from '@endo/eventual-send'; +import type { + ChatParams, + ChatResult, +} from '@metamask/kernel-language-model-service'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Baggage, @@ -41,6 +45,13 @@ import type { ServiceQuery, } from '@metamask/service-discovery-types'; +import type { MatchEntry, ServiceDigest } from './ranker.ts'; +import { + MATCHER_SYSTEM_PROMPT, + formatRankingPrompt, + parseMatches, +} from './ranker.ts'; + type RegisteredService = { id: string; description: ServiceDescription; @@ -48,44 +59,20 @@ type RegisteredService = { }; /** - * The vat-facing shape of an `IOService`. The kernel-side - * implementation lives in `packages/ocap-kernel/src/io/io-service.ts` - * and is wired up via the cluster config's `io` block. + * The vat-facing shape of the `languageModelService` kernel service. + * Kernel services exclude the streaming `chat` overload because an + * `AsyncIterable` cannot cross the kernel marshal boundary. */ -type IOService = { - read: () => Promise; - write: (data: string) => Promise; +type LanguageModelService = { + chat: (params: ChatParams & { stream?: false }) => Promise; }; type Services = { ocapURLIssuerService: OcapURLIssuerService; ocapURLRedemptionService: OcapURLRedemptionService; - llm: IOService; -}; - -/** Wire-protocol shapes — must agree with `@ocap/llm-bridge`'s `protocol.ts`. */ -type IngestRequest = { - kind: 'ingest'; - service: { - id: string; - description: string; - methods: { name: string; description?: string }[]; - }; -}; - -type QueryRequest = { - kind: 'query'; - query: string; + languageModelService: LanguageModelService; }; -type IngestedReply = { kind: 'ingested' }; -type MatchesReply = { - kind: 'matches'; - matches: { id: string; rationale: string }[]; -}; -type ErrorReply = { kind: 'error'; message: string }; -type Reply = IngestedReply | MatchesReply | ErrorReply; - /** * Vat-data primitives we need from the `vatPowers` argument. Provided * by swingset-liveslots; see liveslots.js → vatGlobals.VatData. @@ -108,7 +95,10 @@ type VatPowers = { * * @param vatPowers - Vat powers; `VatData` is required for the durable * publicFacet. - * @param _parameters - Parameters passed to the vat (unused). + * @param parameters - Parameters passed to the vat. `model` (required) + * is the model name sent with every ranking request — for an openclaw + * gateway this is an agent target like `openclaw` or + * `openclaw/`. * @param baggage - Vat baggage. The matcher uses it to make the * publicFacet's kref durable, and to remember (across restarts) the * services bag and the issued matcher URL. @@ -118,7 +108,7 @@ type VatPowers = { // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function buildRootObject( vatPowers: VatPowers, - _parameters: Record, + parameters: Record, baggage: Baggage, ) { const { VatData } = vatPowers; @@ -128,6 +118,14 @@ export function buildRootObject( ); } + const { model: modelParameter } = parameters; + if (typeof modelParameter !== 'string' || modelParameter.length === 0) { + throw new Error( + 'matcher vat: a non-empty "model" vat parameter is required', + ); + } + const model: string = modelParameter; + // Registry is in-memory. On matcher restart it starts empty; // providers must re-register. Making this durable is a planned // follow-up; see the file header for the obligations it carries. @@ -251,124 +249,57 @@ export function buildRootObject( return out; } - // ------------------------------------------------------------------- - // Bridge mutex. - // - // The vat may have many register* / findServices calls in flight - // concurrently. The bridge channel is a single line-stream and the - // protocol is strictly request-then-reply, so we serialize round-trips - // through a chained promise to keep replies matched to their requests. - // ------------------------------------------------------------------- - - let bridgeChain: Promise = Promise.resolve(); - /** - * Run `fn` only after the previous bridge round-trip has settled. - * Errors from the previous holder are swallowed so they don't poison - * subsequent calls. + * Project a registry entry into the compact digest the ranking + * prompt presents to the model. * - * @param fn - The bridge round-trip to serialize. - * @returns Whatever `fn` returns. + * @param entry - The registry entry. + * @returns The service digest. */ - async function withBridgeLock( - fn: () => Promise, - ): Promise { - const previous = bridgeChain; - let release: () => void = () => undefined; - bridgeChain = new Promise((resolve) => { - release = resolve; - }); - try { - // Wait for the previous holder to finish; ignore its outcome — - // an error there shouldn't poison subsequent calls. - await previous.catch(() => undefined); - return await fn(); - } finally { - release(); - } - } - - /** - * Send a single request to the bridge over the `llm` IOService and - * await its single-line reply. - * - * @param request - The request object to send (will be JSON-encoded). - * @returns The parsed reply. - * @throws If the channel has closed, or the reply isn't valid JSON. - */ - async function bridgeRoundTrip( - request: IngestRequest | QueryRequest, - ): Promise { - return withBridgeLock(async () => { - const { llm } = getServices(); - // The kernel-side IOChannel appends '\n' itself, so we just send - // the JSON-encoded request body. - await E(llm).write(JSON.stringify(request)); - const line = await E(llm).read(); - if (line === null) { - throw new Error('matcher vat: llm bridge channel closed'); - } - try { - return JSON.parse(line) as Reply; - } catch { - throw new Error( - `matcher vat: llm bridge sent unparseable line: ${line}`, - ); - } - }); - } - - /** - * Tell the bridge to ingest a new service registration into its - * conversation context. - * - * @param id - The matcher's local registry id for the service. - * @param description - The full service description. - * @throws If the bridge replies with `error` or any non-`ingested` kind. - */ - async function ingestService( - id: string, - description: ServiceDescription, - ): Promise { - const request: IngestRequest = { - kind: 'ingest', - service: { - id, - description: description.description, - methods: extractMethodDigests(description.apiSpec), - }, + function entryDigest(entry: RegisteredService): ServiceDigest { + return { + id: entry.id, + description: entry.description.description, + methods: extractMethodDigests(entry.description.apiSpec), }; - const reply = await bridgeRoundTrip(request); - if (reply.kind === 'error') { - throw new Error(`matcher vat: bridge ingest error: ${reply.message}`); - } - if (reply.kind !== 'ingested') { - throw new Error( - `matcher vat: unexpected bridge reply kind for ingest: ${reply.kind}`, - ); - } } /** - * Ask the bridge to rank registered services against a query. + * Rank the registered services against a query via the + * `languageModelService`. Stateless: the full current registry rides + * along in the prompt, so concurrent calls are independent and need + * no serialization. * * @param query - The free-text query from the consumer. - * @returns The ranked matches the bridge returned. - * @throws If the bridge replies with `error` or any non-`matches` kind. + * @returns The ranked matches the model returned. + * @throws If the LLM call fails or its reply isn't a valid match list. */ - async function queryServicesViaBridge( - query: string, - ): Promise<{ id: string; rationale: string }[]> { - const reply = await bridgeRoundTrip({ kind: 'query', query }); - if (reply.kind === 'error') { - throw new Error(`matcher vat: bridge query error: ${reply.message}`); + async function rankServices(query: string): Promise { + if (registry.size === 0) { + // Nothing registered — no point burning a model call to learn + // that nothing matches. + return []; } - if (reply.kind !== 'matches') { - throw new Error( - `matcher vat: unexpected bridge reply kind for query: ${reply.kind}`, - ); + const { languageModelService } = getServices(); + const params: ChatParams & { stream?: false } = { + model, + messages: [ + { role: 'system', content: MATCHER_SYSTEM_PROMPT }, + { + role: 'user', + content: formatRankingPrompt( + [...registry.values()].map(entryDigest), + query, + ), + }, + ], + }; + const result = await E(languageModelService).chat(harden(params)); + const content = result.choices[0]?.message?.content; + if (typeof content !== 'string') { + throw new Error('matcher vat: LLM reply contained no message content'); } - return reply.matches; + return parseMatches(content); } /** @@ -443,53 +374,27 @@ export function buildRootObject( /** * Final step shared by all `register*` paths: evict any superseded - * registrations, store the new entry locally, and tell the bridge. If - * the bridge call fails, undo the local store so the registry never - * contains entries the LLM doesn't know about. + * registrations and store the new entry locally. Purely local — the + * LLM only ever sees the registry at query time, so there is no + * model-side state to update (or roll back) here. * * Eviction key is (peerId, providerTag) — see ServiceDescription's - * `providerTag` for the contract. The dead `svc:N` entries that get - * evicted here may still be cited by the LLM bridge's stale - * conversation context; `findServices` filters those out via the - * existing "bridge cited unknown id" guard. + * `providerTag` for the contract. * * @param description - The validated service description. * @param contact - The validated contact endpoint. */ - async function commitRegistration( + function commitRegistration( description: ServiceDescription, contact: ContactPoint, - ): Promise { - // Stash superseded entries so we can restore them if the bridge - // ingest fails — otherwise a transient bridge failure would - // silently destroy whatever previous registration shared this - // providerTag, defeating the atomicity property the dedup commit - // was supposed to provide. - const evicted: [string, RegisteredService][] = []; + ): void { for (const supersededId of findSamePeerSameTagEntries(description)) { - const prior = registry.get(supersededId); - if (prior) { - evicted.push([supersededId, prior]); - } registry.delete(supersededId); log( `evicted superseded registration ${supersededId} (providerTag=${description.providerTag})`, ); } - const id = store(description, contact); - try { - await ingestService(id, description); - } catch (cause) { - registry.delete(id); - for (const [oldId, oldEntry] of evicted) { - registry.set(oldId, oldEntry); - } - log( - `bridge ingest failed for ${id}; rolled back new entry and restored ${evicted.length} evicted entries:`, - cause, - ); - throw cause; - } + store(description, contact); } // Behavior methods for `defineDurableKind` receive a `context` arg @@ -514,7 +419,7 @@ export function buildRootObject( contactUrl, )) as ContactPoint; await confirmRegistration(contact, registrationToken); - await commitRegistration(description, contact); + commitRegistration(description, contact); }, async registerServiceByUrl( @@ -531,7 +436,7 @@ export function buildRootObject( // into work the matcher performs on the victim's behalf. await confirmRegistration(contact, registrationToken); const description = await E(contact).getServiceDescription(); - await commitRegistration(description, contact); + commitRegistration(description, contact); }, async registerServiceByRef( @@ -542,24 +447,24 @@ export function buildRootObject( // Confirm FIRST — see comment above in registerServiceByUrl. await confirmRegistration(contact, registrationToken); const description = await E(contact).getServiceDescription(); - await commitRegistration(description, contact); + commitRegistration(description, contact); }, async findServices( _context: unknown, query: ServiceQuery, ): Promise { - const ranked = await queryServicesViaBridge(query.description); + const ranked = await rankServices(query.description); const matches: ServiceMatch[] = []; let dropped = 0; for (const entry of ranked) { const registered = registry.get(entry.id); if (!registered) { - // The bridge cited an id we no longer have — could happen if - // the LLM hallucinates one, or if a service was unregistered - // between ingest and query. Skip and log; don't bubble up. + // The model cited an id we don't have — it can only be a + // hallucination, since the prompt carried the current + // registry. Skip and log; don't bubble up. dropped += 1; - log(`bridge cited unknown id ${entry.id}; skipping`); + log(`model cited unknown id ${entry.id}; skipping`); continue; } matches.push( @@ -569,15 +474,14 @@ export function buildRootObject( }), ); } - // If the bridge offered candidates and *all* of them were - // unknown, the registry is either out of sync with the bridge or - // the ranker is hallucinating ids wholesale. Loud failure is - // better than silently returning [], which would be + // If the model offered candidates and *all* of them were + // unknown, the ranker is hallucinating ids wholesale. Loud + // failure is better than silently returning [], which would be // indistinguishable from a legitimate zero-match query. if (ranked.length > 0 && matches.length === 0) { throw new Error( - `matcher: LLM bridge cited only unknown ids (${dropped}/${ranked.length}); ` + - 'registry may be out of sync or ranker is hallucinating.', + `matcher: LLM cited only unknown ids (${dropped}/${ranked.length}); ` + + 'the ranker is hallucinating.', ); } log( @@ -611,9 +515,10 @@ export function buildRootObject( if (!incoming.ocapURLRedemptionService) { throw new Error('ocapURLRedemptionService is required'); } - if (!incoming.llm) { + if (!incoming.languageModelService) { throw new Error( - 'llm IOService is required (configure it in the cluster config under `io.llm`)', + 'languageModelService is required (list it in the cluster config ' + + "`services` and configure the daemon's llm.json)", ); } // Persist services so the durable matcher facet's behavior can diff --git a/packages/service-matcher/src/matcher-vat/ranker.test.ts b/packages/service-matcher/src/matcher-vat/ranker.test.ts new file mode 100644 index 0000000000..3d98a0cc9a --- /dev/null +++ b/packages/service-matcher/src/matcher-vat/ranker.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import type { ServiceDigest } from './ranker.ts'; +import { formatRankingPrompt, parseMatches } from './ranker.ts'; + +const sampleDigests: ServiceDigest[] = [ + { + id: 'svc:0', + description: 'Signs messages with a personal key', + methods: [ + { name: 'signMessage', description: 'Sign a personal message' }, + { name: 'getAccounts' }, + ], + }, + { + id: 'svc:1', + description: 'Generates random numbers', + methods: [], + }, +]; + +describe('formatRankingPrompt', () => { + it('includes every service id, description, and method', () => { + const prompt = formatRankingPrompt(sampleDigests, 'sign something'); + expect(prompt).toContain('Service svc:0:'); + expect(prompt).toContain('Signs messages with a personal key'); + expect(prompt).toContain('- signMessage: Sign a personal message'); + expect(prompt).toContain('- getAccounts'); + expect(prompt).toContain('Service svc:1:'); + expect(prompt).toContain('(no methods documented)'); + }); + + it('includes the query after the registry', () => { + const prompt = formatRankingPrompt(sampleDigests, 'sign something'); + expect(prompt.indexOf('Query: sign something')).toBeGreaterThan( + prompt.indexOf('Service svc:1:'), + ); + }); + + it('repeats the JSON-only output rule', () => { + const prompt = formatRankingPrompt(sampleDigests, 'q'); + expect(prompt).toContain('Reply with JSON ONLY'); + }); +}); + +describe('parseMatches', () => { + it('parses a plain JSON match list', () => { + expect( + parseMatches('[{"id":"svc:0","rationale":"it signs"}]'), + ).toStrictEqual([{ id: 'svc:0', rationale: 'it signs' }]); + }); + + it('parses an empty list', () => { + expect(parseMatches('[]')).toStrictEqual([]); + }); + + it('tolerates a markdown code fence', () => { + expect( + parseMatches('```json\n[{"id":"svc:1","rationale":"r"}]\n```'), + ).toStrictEqual([{ id: 'svc:1', rationale: 'r' }]); + }); + + it.each([ + ['non-JSON prose', 'no matches found, sorry!', /not parseable JSON/u], + ['a JSON object', '{"id":"svc:0"}', /not a JSON array/u], + ['an array of strings', '["svc:0"]', /non-object/u], + [ + 'entries missing rationale', + '[{"id":"svc:0"}]', + /missing string id\/rationale/u, + ], + ])('throws on %s', (_case, reply, expected) => { + expect(() => parseMatches(reply)).toThrow(expected); + }); +}); diff --git a/packages/service-matcher/src/matcher-vat/ranker.ts b/packages/service-matcher/src/matcher-vat/ranker.ts new file mode 100644 index 0000000000..b508047dd0 --- /dev/null +++ b/packages/service-matcher/src/matcher-vat/ranker.ts @@ -0,0 +1,126 @@ +/** + * LLM ranking for the matcher vat: prompt construction and reply + * parsing. Pure functions — the vat supplies the registry digests and + * performs the actual `languageModelService` call. + * + * Ranking is stateless: every query presents the full current registry + * to the model in a single completion request. There is no conversation + * to keep in sync with the registry, so registrations never involve the + * LLM and the model can never cite a stale entry that the registry has + * already evicted. + */ + +/** Per-method digest included in the ranking prompt. */ +export type MethodDigest = { + name: string; + description?: string; +}; + +/** Compact projection of a registered service for the ranking prompt. */ +export type ServiceDigest = { + id: string; + description: string; + methods: MethodDigest[]; +}; + +/** A single ranked match cited by the model. */ +export type MatchEntry = { + id: string; + rationale: string; +}; + +export const MATCHER_SYSTEM_PROMPT = `You are a service-discovery matcher. You rank registered services against natural-language queries. + +You will receive a registry of services — each with an opaque ID, a one-sentence description, and a list of method names with optional descriptions — followed by a query describing a user intent. + +Reply with a JSON array AND NOTHING ELSE — no prose, no commentary, no markdown code fences. Each array element must be an object of the form {"id":"","rationale":""}. Order best-first. If no service matches, reply []. Never invent IDs that are not in the registry.`; + +/** + * Format a single service digest as a block in the ranking prompt. + * + * @param digest - The service digest. + * @returns The formatted registry block. + */ +function formatDigest(digest: ServiceDigest): string { + const methodLines = + digest.methods.length === 0 + ? ' (no methods documented)' + : digest.methods + .map( + (method) => + ` - ${method.name}${method.description ? `: ${method.description}` : ''}`, + ) + .join('\n'); + return [ + `Service ${digest.id}:`, + ` Description: ${digest.description}`, + ` Methods:`, + methodLines, + ].join('\n'); +} + +/** + * Format the user turn of a ranking request: the full registry followed + * by the query. Repeats the JSON-only output rule inline so it can't be + * lost in a long context window. + * + * @param digests - Digests of every currently registered service. + * @param query - The free-text query. + * @returns The user-message content. + */ +export function formatRankingPrompt( + digests: ServiceDigest[], + query: string, +): string { + return [ + 'Registry:', + '', + ...digests.map(formatDigest), + '', + `Query: ${query}`, + '', + 'Reply with a JSON array of {"id","rationale"} objects, ranked best-first, or [] if nothing matches. Reply with JSON ONLY — no prose, no markdown code fences.', + ].join('\n'); +} + +/** + * Parse the LLM's textual reply as a match list. Tolerates an outer + * markdown code fence (some models add one despite instructions) but + * otherwise insists on the exact `[{id, rationale}, ...]` shape. + * + * @param reply - The raw text returned by the LLM. + * @returns The parsed match list. + * @throws If the reply isn't JSON, isn't an array, or any entry is + * missing the `id`/`rationale` strings. + */ +export function parseMatches(reply: string): MatchEntry[] { + const trimmed = reply + .trim() + .replace(/^```(?:json)?\s*/iu, '') + .replace(/\s*```$/u, '') + .trim(); + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error(`LLM reply was not parseable JSON: ${reply}`); + } + if (!Array.isArray(parsed)) { + throw new Error(`LLM reply was not a JSON array: ${reply}`); + } + const result: MatchEntry[] = []; + for (const entry of parsed) { + if (typeof entry !== 'object' || entry === null) { + throw new Error(`LLM reply array contained a non-object: ${reply}`); + } + const { id } = entry as Record; + const { rationale } = entry as Record; + if (typeof id !== 'string' || typeof rationale !== 'string') { + throw new Error( + `LLM reply array entry missing string id/rationale: ${reply}`, + ); + } + result.push({ id, rationale }); + } + return result; +} diff --git a/packages/service-matcher/tsconfig.build.json b/packages/service-matcher/tsconfig.build.json index fa25fe098c..58fa5910d5 100644 --- a/packages/service-matcher/tsconfig.build.json +++ b/packages/service-matcher/tsconfig.build.json @@ -8,6 +8,7 @@ "types": [] }, "references": [ + { "path": "../kernel-language-model-service/tsconfig.build.json" }, { "path": "../kernel-utils/tsconfig.build.json" }, { "path": "../ocap-kernel/tsconfig.build.json" }, { "path": "../service-discovery-types/tsconfig.build.json" } diff --git a/packages/service-matcher/tsconfig.json b/packages/service-matcher/tsconfig.json index 46e0581ca6..46fb739da1 100644 --- a/packages/service-matcher/tsconfig.json +++ b/packages/service-matcher/tsconfig.json @@ -6,6 +6,7 @@ "types": ["vitest"] }, "references": [ + { "path": "../kernel-language-model-service" }, { "path": "../kernel-utils" }, { "path": "../ocap-kernel" }, { "path": "../repo-tools" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 3eacbee1fd..22ebb2c024 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -14,7 +14,6 @@ { "path": "./packages/kernel-rpc-methods/tsconfig.build.json" }, { "path": "./packages/kernel-store/tsconfig.build.json" }, { "path": "./packages/kernel-utils/tsconfig.build.json" }, - { "path": "./packages/llm-bridge/tsconfig.build.json" }, { "path": "./packages/logger/tsconfig.build.json" }, { "path": "./packages/nodejs-test-workers/tsconfig.build.json" }, { "path": "./packages/ocap-kernel/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index fca57f6805..711dbe403a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,6 @@ { "path": "./packages/kernel-store" }, { "path": "./packages/kernel-ui" }, { "path": "./packages/kernel-utils" }, - { "path": "./packages/llm-bridge" }, { "path": "./packages/logger" }, { "path": "./packages/nodejs-test-workers" }, { "path": "./packages/ocap-kernel" }, diff --git a/yarn.lock b/yarn.lock index f5c8836960..b99e33fcb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2350,10 +2350,12 @@ __metadata: "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-language-model-service": "workspace:^" "@metamask/kernel-node-runtime": "workspace:^" "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" + "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.9.0" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" @@ -2435,6 +2437,49 @@ __metadata: languageName: unknown linkType: soft +"@metamask/kernel-language-model-service@workspace:^, @metamask/kernel-language-model-service@workspace:packages/kernel-language-model-service": + version: 0.0.0-use.local + resolution: "@metamask/kernel-language-model-service@workspace:packages/kernel-language-model-service" + dependencies: + "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/eventual-send": "npm:^1.3.4" + "@metamask/auto-changelog": "npm:^5.3.0" + "@metamask/eslint-config": "npm:^15.0.0" + "@metamask/eslint-config-nodejs": "npm:^15.0.0" + "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-utils": "workspace:^" + "@metamask/streams": "workspace:^" + "@metamask/superstruct": "npm:^3.2.1" + "@ocap/repo-tools": "workspace:^" + "@ts-bridge/cli": "npm:^0.6.3" + "@ts-bridge/shims": "npm:^0.1.1" + "@types/chrome": "npm:^0.0.313" + "@typescript-eslint/eslint-plugin": "npm:^8.29.0" + "@typescript-eslint/parser": "npm:^8.29.0" + "@typescript-eslint/utils": "npm:^8.29.0" + "@vitest/eslint-plugin": "npm:^1.6.14" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + eslint-config-prettier: "npm:^10.1.1" + eslint-import-resolver-typescript: "npm:^4.3.1" + eslint-plugin-import-x: "npm:^4.10.0" + eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-promise: "npm:^7.2.1" + ollama: "npm:^0.5.16" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + ses: "npm:^1.14.0" + turbo: "npm:^2.9.1" + typedoc: "npm:^0.28.1" + typescript: "npm:~5.8.2" + typescript-eslint: "npm:^8.29.0" + vite: "npm:^8.0.6" + vitest: "npm:^4.1.3" + languageName: unknown + linkType: soft + "@metamask/kernel-node-runtime@workspace:^, @metamask/kernel-node-runtime@workspace:packages/kernel-node-runtime": version: 0.0.0-use.local resolution: "@metamask/kernel-node-runtime@workspace:packages/kernel-node-runtime" @@ -3987,10 +4032,10 @@ __metadata: "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" "@metamask/kernel-errors": "workspace:^" + "@metamask/kernel-language-model-service": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/superstruct": "npm:^3.2.1" - "@ocap/kernel-language-model-service": "workspace:^" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" @@ -4021,49 +4066,6 @@ __metadata: languageName: unknown linkType: soft -"@ocap/kernel-language-model-service@workspace:^, @ocap/kernel-language-model-service@workspace:packages/kernel-language-model-service": - version: 0.0.0-use.local - resolution: "@ocap/kernel-language-model-service@workspace:packages/kernel-language-model-service" - dependencies: - "@arethetypeswrong/cli": "npm:^0.17.4" - "@endo/eventual-send": "npm:^1.3.4" - "@metamask/auto-changelog": "npm:^5.3.0" - "@metamask/eslint-config": "npm:^15.0.0" - "@metamask/eslint-config-nodejs": "npm:^15.0.0" - "@metamask/eslint-config-typescript": "npm:^15.0.0" - "@metamask/kernel-utils": "workspace:^" - "@metamask/streams": "workspace:^" - "@metamask/superstruct": "npm:^3.2.1" - "@ocap/repo-tools": "workspace:^" - "@ts-bridge/cli": "npm:^0.6.3" - "@ts-bridge/shims": "npm:^0.1.1" - "@types/chrome": "npm:^0.0.313" - "@typescript-eslint/eslint-plugin": "npm:^8.29.0" - "@typescript-eslint/parser": "npm:^8.29.0" - "@typescript-eslint/utils": "npm:^8.29.0" - "@vitest/eslint-plugin": "npm:^1.6.14" - depcheck: "npm:^1.4.7" - eslint: "npm:^9.23.0" - eslint-config-prettier: "npm:^10.1.1" - eslint-import-resolver-typescript: "npm:^4.3.1" - eslint-plugin-import-x: "npm:^4.10.0" - eslint-plugin-jsdoc: "npm:^50.6.9" - eslint-plugin-n: "npm:^17.17.0" - eslint-plugin-prettier: "npm:^5.2.6" - eslint-plugin-promise: "npm:^7.2.1" - ollama: "npm:^0.5.16" - prettier: "npm:^3.5.3" - rimraf: "npm:^6.0.1" - ses: "npm:^1.14.0" - turbo: "npm:^2.9.1" - typedoc: "npm:^0.28.1" - typescript: "npm:~5.8.2" - typescript-eslint: "npm:^8.29.0" - vite: "npm:^8.0.6" - vitest: "npm:^4.1.3" - languageName: unknown - linkType: soft - "@ocap/kernel-test-local@workspace:packages/kernel-test-local": version: 0.0.0-use.local resolution: "@ocap/kernel-test-local@workspace:packages/kernel-test-local" @@ -4074,6 +4076,7 @@ __metadata: "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" "@metamask/kernel-cli": "workspace:^" + "@metamask/kernel-language-model-service": "workspace:^" "@metamask/kernel-node-runtime": "workspace:^" "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-store": "workspace:^" @@ -4082,7 +4085,6 @@ __metadata: "@metamask/ocap-kernel": "workspace:^" "@ocap/kernel-agents": "workspace:^" "@ocap/kernel-agents-repl": "workspace:^" - "@ocap/kernel-language-model-service": "workspace:^" "@ocap/repo-tools": "workspace:^" "@types/node": "npm:^22.13.1" "@typescript-eslint/eslint-plugin": "npm:^8.29.0" @@ -4125,13 +4127,13 @@ __metadata: "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" "@metamask/kernel-cli": "workspace:^" + "@metamask/kernel-language-model-service": "workspace:^" "@metamask/kernel-node-runtime": "workspace:^" "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-store": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" - "@ocap/kernel-language-model-service": "workspace:^" "@ocap/nodejs-test-workers": "workspace:^" "@ocap/remote-iterables": "workspace:^" "@ocap/repo-tools": "workspace:^" @@ -4159,46 +4161,6 @@ __metadata: languageName: unknown linkType: soft -"@ocap/llm-bridge@workspace:packages/llm-bridge": - version: 0.0.0-use.local - resolution: "@ocap/llm-bridge@workspace:packages/llm-bridge" - dependencies: - "@arethetypeswrong/cli": "npm:^0.17.4" - "@metamask/auto-changelog": "npm:^5.3.0" - "@metamask/eslint-config": "npm:^15.0.0" - "@metamask/eslint-config-nodejs": "npm:^15.0.0" - "@metamask/eslint-config-typescript": "npm:^15.0.0" - "@metamask/superstruct": "npm:^3.2.1" - "@ocap/repo-tools": "workspace:^" - "@ts-bridge/cli": "npm:^0.6.3" - "@ts-bridge/shims": "npm:^0.1.1" - "@types/node": "npm:^22.13.1" - "@typescript-eslint/eslint-plugin": "npm:^8.29.0" - "@typescript-eslint/parser": "npm:^8.29.0" - "@typescript-eslint/utils": "npm:^8.29.0" - "@vitest/eslint-plugin": "npm:^1.6.14" - depcheck: "npm:^1.4.7" - eslint: "npm:^9.23.0" - eslint-config-prettier: "npm:^10.1.1" - eslint-import-resolver-typescript: "npm:^4.3.1" - eslint-plugin-import-x: "npm:^4.10.0" - eslint-plugin-jsdoc: "npm:^50.6.9" - eslint-plugin-n: "npm:^17.17.0" - eslint-plugin-prettier: "npm:^5.2.6" - eslint-plugin-promise: "npm:^7.2.1" - prettier: "npm:^3.5.3" - rimraf: "npm:^6.0.1" - turbo: "npm:^2.9.1" - typedoc: "npm:^0.28.1" - typescript: "npm:~5.8.2" - typescript-eslint: "npm:^8.29.0" - vite: "npm:^8.0.6" - vitest: "npm:^4.1.3" - bin: - ocap-llm-bridge: ./dist/index.mjs - languageName: unknown - linkType: soft - "@ocap/monorepo@workspace:.": version: 0.0.0-use.local resolution: "@ocap/monorepo@workspace:." @@ -4491,6 +4453,7 @@ __metadata: "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-language-model-service": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/ocap-kernel": "workspace:^" "@metamask/service-discovery-types": "workspace:^"