diff --git a/package.json b/package.json index e15a17c89..ee8f5723f 100644 --- a/package.json +++ b/package.json @@ -112,10 +112,13 @@ "vite>sass>@parcel/watcher": false, "vitest>@vitest/browser>webdriverio>@wdio/utils>edgedriver": false, "vitest>@vitest/browser>webdriverio>@wdio/utils>geckodriver": false, - "vitest>@vitest/mocker>msw": false + "vitest>@vitest/mocker>msw": false, + "vite>tsx>esbuild": false } }, "resolutions": { - "cookie": "^0.7.0" + "cookie": "^0.7.0", + "form-data": "4.0.2", + "foreground-child": "3.1.1" } } diff --git a/packages/kernel/src/Kernel.ts b/packages/kernel/src/Kernel.ts index 852044dbd..7e256a905 100644 --- a/packages/kernel/src/Kernel.ts +++ b/packages/kernel/src/Kernel.ts @@ -47,6 +47,8 @@ import { } from './types.ts'; import { VatHandle } from './VatHandle.ts'; +const VERBOSE = false; + /** * Obtain the KRef from a simple value represented as a CapData object. * @@ -67,6 +69,10 @@ type MessageRoute = { target: KRef; } | null; +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const clip = (content: string, length = 10) => + `${content.substring(0, length)}${content.length > length ? '...' : ''}`; + export class Kernel { /** Command channel from the controlling console/browser extension/test driver */ readonly #commandStream: DuplexStream; @@ -506,16 +512,16 @@ export class Kernel { * @param item - The message/notification to deliver. */ async #deliver(item: RunQueueItem): Promise { - const { log } = console; + const log = VERBOSE ? console.log : (_: unknown) => undefined; + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const glimpse = (obj: unknown) => clip(JSON.stringify(obj)); switch (item.type) { case 'send': { const route = this.#routeMessage(item); if (route) { const { vatId, target } = route; const { message } = item; - log( - `@@@@ deliver ${vatId} send ${target}<-${JSON.stringify(message)}`, - ); + log(`@@@@ deliver ${vatId} send ${target}<-${glimpse(message)}`); if (vatId) { const vat = this.#getVat(vatId); if (vat) { @@ -534,7 +540,7 @@ export class Kernel { } else { this.#storage.enqueuePromiseMessage(target, message); } - log(`@@@@ done ${vatId} send ${target}<-${JSON.stringify(message)}`); + log(`@@@@ done ${vatId} send ${target}<-${glimpse(message)}`); } break; } diff --git a/packages/kernel/src/VatHandle.ts b/packages/kernel/src/VatHandle.ts index 1af3a3a8d..c7e2f7a77 100644 --- a/packages/kernel/src/VatHandle.ts +++ b/packages/kernel/src/VatHandle.ts @@ -27,6 +27,8 @@ import type { RunQueueItemSend, } from './types.ts'; +const VERBOSE = false; + type VatConstructorProps = { kernel: Kernel; vatId: VatId; @@ -36,6 +38,10 @@ type VatConstructorProps = { logger?: Logger | undefined; }; +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const clip = (content: string, length = 10) => + `${content.substring(0, length)}${content.length > length ? '...' : ''}`; + export class VatHandle { /** The ID of the vat this is the VatHandle for */ readonly vatId: VatId; @@ -323,12 +329,14 @@ export class VatHandle { const kso: VatSyscallObject = this.#translateSyscallVtoK(vso); const [op] = kso; const { vatId } = this; - const { log } = console; + const log = VERBOSE ? console.log : (_: unknown) => undefined; + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const glimpse = (obj: unknown) => clip(JSON.stringify(obj)); switch (op) { case 'send': { // [KRef, Message]; const [, target, message] = kso; - log(`@@@@ ${vatId} syscall send ${target}<-${JSON.stringify(message)}`); + log(`@@@@ ${vatId} syscall send ${target}<-${glimpse(message)}`); this.#handleSyscallSend(target, message); break; } @@ -342,38 +350,38 @@ export class VatHandle { case 'resolve': { // [VatOneResolution[]]; const [, resolutions] = kso; - log(`@@@@ ${vatId} syscall resolve ${JSON.stringify(resolutions)}`); + log(`@@@@ ${vatId} syscall resolve ${glimpse(resolutions)}`); this.#handleSyscallResolve(resolutions as VatOneResolution[]); break; } case 'exit': { // [boolean, SwingSetCapData]; const [, fail, info] = kso; - log(`@@@@ ${vatId} syscall exit fail=${fail} ${JSON.stringify(info)}`); + log(`@@@@ ${vatId} syscall exit fail=${fail} ${glimpse(info)}`); break; } case 'dropImports': { // [KRef[]]; const [, refs] = kso; - log(`@@@@ ${vatId} syscall dropImports ${JSON.stringify(refs)}`); + log(`@@@@ ${vatId} syscall dropImports ${glimpse(refs)}`); break; } case 'retireImports': { // [KRef[]]; const [, refs] = kso; - log(`@@@@ ${vatId} syscall retireImports ${JSON.stringify(refs)}`); + log(`@@@@ ${vatId} syscall retireImports ${glimpse(refs)}`); break; } case 'retireExports': { // [KRef[]]; const [, refs] = kso; - log(`@@@@ ${vatId} syscall retireExports ${JSON.stringify(refs)}`); + log(`@@@@ ${vatId} syscall retireExports ${glimpse(refs)}`); break; } case 'abandonExports': { // [KRef[]]; const [, refs] = kso; - log(`@@@@ ${vatId} syscall abandonExports ${JSON.stringify(refs)}`); + log(`@@@@ ${vatId} syscall abandonExports ${glimpse(refs)}`); break; } case 'callNow': diff --git a/packages/kernel/src/VatSupervisor.ts b/packages/kernel/src/VatSupervisor.ts index 4c5925338..8de370603 100644 --- a/packages/kernel/src/VatSupervisor.ts +++ b/packages/kernel/src/VatSupervisor.ts @@ -33,6 +33,7 @@ type SupervisorConstructorProps = { id: VatId; commandStream: DuplexStream; makeKVStore: MakeKVStore; + makePowers?: () => Promise>; fetchBlob?: FetchBlob; }; @@ -56,6 +57,9 @@ export class VatSupervisor { /** Capability to create the store for this vat. */ readonly #makeKVStore: MakeKVStore; + /** An initialization routine for powers bestowed to this vat. */ + readonly #makePowers: () => Promise>; + /** Capability to fetch the bundle of code to run in this vat. */ readonly #fetchBlob: FetchBlob; @@ -69,17 +73,20 @@ export class VatSupervisor { * @param params.id - The id of the vat being supervised. * @param params.commandStream - Communications channel connected to the kernel. * @param params.makeKVStore - Capability to create the store for this vat. + * @param params.makePowers - Capability to create powers this vat. * @param params.fetchBlob - Function to fetch the user code bundle for this vat. */ constructor({ id, commandStream, makeKVStore, + makePowers, fetchBlob, }: SupervisorConstructorProps) { this.id = id; this.#commandStream = commandStream; this.#makeKVStore = makeKVStore; + this.#makePowers = makePowers ?? (async () => ({})); this.#dispatch = null; const defaultFetchBlob: FetchBlob = async (bundleURL: string) => fetch(bundleURL); @@ -219,7 +226,7 @@ export class VatSupervisor { `[vat-${this.id}]`, ); const syscall = makeSupervisorSyscall(this, kvStore); - const vatPowers = {}; // XXX should be something more real + const vatPowers = await this.#makePowers(); const liveSlotsOptions = {}; // XXX should be something more real const gcTools: GCTools = harden({ diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index c0f77e315..c76f0ff9f 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -23,6 +23,10 @@ "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/nodejs", "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist", + "demo:rag": "node ./dist/demo/rag/run.mjs", + "demo:rag:ci": "./scripts/demo-rag-ci.sh", + "demo:console": "node ./dist/demo/console/run.mjs", + "demo:console:ci": "./scripts/demo-console-ci.sh", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck", "lint:eslint": "eslint . --cache", @@ -38,12 +42,25 @@ "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { + "@endo/base64": "^1.0.9", + "@endo/eventual-send": "^1.2.6", + "@endo/exo": "^1.5.8", + "@endo/far": "^1.1.9", + "@endo/marshal": "^1.6.2", + "@endo/pass-style": "^1.4.7", + "@endo/patterns": "^1.4.8", "@endo/promise-kit": "^1.1.6", + "@endo/stream": "^1.2.6", + "@langchain/core": "^0.3.37", + "@langchain/ollama": "^0.1.5", + "@langchain/textsplitters": "^0.1.0", "@ocap/kernel": "workspace:^", "@ocap/shims": "workspace:^", "@ocap/store": "workspace:^", "@ocap/streams": "workspace:^", "@ocap/utils": "workspace:^", + "langchain": "^0.3.15", + "ollama": "^0.5.12", "ses": "^1.9.0" }, "devDependencies": { diff --git a/packages/nodejs/scripts/call-ollama.js b/packages/nodejs/scripts/call-ollama.js new file mode 100644 index 000000000..6d9dc2552 --- /dev/null +++ b/packages/nodejs/scripts/call-ollama.js @@ -0,0 +1,101 @@ +import { readFile } from 'fs/promises'; +import ollama from 'ollama'; + +/** + * Streams the response from Ollama to the console. + * + * @param {*} response - The response from Ollama. + */ +async function streamResponse(response) { + const thinkEndToken = ''; + const thinkLabel = 'OLLAMA Thought'; + const thinkingDots = ['.', '..', '...']; + const dotInterval = 400; + let thinkingDotsIndex = 0; + let thinking = true; + let accumulatedContent = ''; + const thinkingInterval = setInterval(() => { + process.stdout.clearLine(); + process.stdout.write(`OLLAMA Thinking${thinkingDots[thinkingDotsIndex]}\r`); + thinkingDotsIndex += 1; + thinkingDotsIndex %= thinkingDots.length; + }, dotInterval); + console.time(thinkLabel); + for await (const part of response) { + accumulatedContent += part.message.content; + if (thinking) { + if (accumulatedContent.includes(thinkEndToken)) { + process.stdout.clearLine(); + console.timeEnd(thinkLabel); + const tail = accumulatedContent.split(thinkEndToken)[1]; + process.stdout.write(`OLLAMA Response: ${tail}`); + clearInterval(thinkingInterval); + thinking = false; + } + } else { + process.stdout.write(part.message.content); // Write each part of the response to the console + } + } +} + +const getFileContent = async (path) => { + const resolvedPath = new URL(path, import.meta.url).pathname; + return (await readFile(resolvedPath)).toString(); +}; + +/** + * The main function for the script. + * + * @param {*} param0 - An arguments bag. + * @param { string } param0.model - The model to pull and use. + * @param { string } param0.prompt - The prompt to give the model. + */ +async function main({ model, prompt }) { + if (!prompt) { + throw new Error('say something'); + } + + console.log('OLLAMA', 'pull'); + + await ollama.pull({ model }); + + console.log('USER:', prompt); + + const response = await ollama.chat({ + model, // Specify the model you want to use + messages: [ + { + role: 'admin', + content: [ + `You are an instance of LLM model ${model}.`, + `Respond to user requests ${'respectfully'} and ${'informatively'}.`, + ].join(' '), + }, + { + role: 'admin', + content: [ + 'The following is the raw content of the wikipedia page titled "ambient authority".', + await getFileContent('./ambient-authority.txt'), + ].join('\n\n'), + }, + { + role: 'admin', + content: [ + 'The following is the raw content of the wikipedia page titled "confused deputy problem".', + await getFileContent('./confused-deputy-problem.txt'), + ].join('\n\n'), + }, + { role: 'user', content: prompt }, + ], // The message to send + stream: true, // Enable streaming + }); + + await streamResponse(response).catch(console.error); + console.log('\n'); // Add a newline after the streaming response +} + +const model = 'deepseek-r1:1.5b'; + +const [, , prompt] = process.argv; + +main({ model, prompt }).catch(console.error); diff --git a/packages/nodejs/scripts/demo-console-ci.sh b/packages/nodejs/scripts/demo-console-ci.sh new file mode 100755 index 000000000..2e351f5b0 --- /dev/null +++ b/packages/nodejs/scripts/demo-console-ci.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -x +set -e +set -o pipefail + +# We borrow the vat definition from extension for now +yarn ocap bundle "src/demo/console/vats" + +# Start the server in background and capture its PID +yarn ocap serve "src/demo/console/vats" & +SERVER_PID=$! + +function cleanup() { + # Kill the server if it's still running + if kill -0 $SERVER_PID 2>/dev/null; then + kill $SERVER_PID + fi +} +# Ensure we always close the server +trap cleanup EXIT + +yarn demo:console diff --git a/packages/nodejs/scripts/demo-rag-ci.sh b/packages/nodejs/scripts/demo-rag-ci.sh new file mode 100755 index 000000000..21886266c --- /dev/null +++ b/packages/nodejs/scripts/demo-rag-ci.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -x +set -e +set -o pipefail + +# We borrow the vat definition from extension for now +yarn ocap bundle "src/demo/rag/vats" + +# Start the server in background and capture its PID +yarn ocap serve "src/demo/rag/vats" & +SERVER_PID=$! + +function cleanup() { + # Kill the server if it's still running + if kill -0 $SERVER_PID 2>/dev/null; then + kill $SERVER_PID + fi +} +# Ensure we always close the server +trap cleanup EXIT + +yarn demo:rag diff --git a/packages/nodejs/src/demo/console/run.ts b/packages/nodejs/src/demo/console/run.ts new file mode 100644 index 000000000..603b91967 --- /dev/null +++ b/packages/nodejs/src/demo/console/run.ts @@ -0,0 +1,30 @@ +import '@ocap/shims/endoify'; + +import { Kernel } from '@ocap/kernel'; +import type { ClusterConfig } from '@ocap/kernel'; +import { MessageChannel as NodeMessageChannel } from 'node:worker_threads'; + +import { makeSubclusterConfig } from './subclusterConfig.ts'; +import { makeKernel } from '../../kernel/make-kernel.ts'; + +const args = { + verbose: process.argv.includes('--verbose'), +}; + +main(args).catch(console.error); + +/** + * The main function for the demo. + * + * @param options0 - The options for the demo. + * @param options0.verbose - Whether to run the demo in verbose mode. + */ +async function main({ verbose }: { verbose: boolean }): Promise { + // This port does nothing; we don't talk to the Kernel via a console (yet). + const kernelPort = new NodeMessageChannel().port1; + + // Make and start the kernel using the demo's subcluster config. + const kernel: Kernel = await makeKernel({ port: kernelPort }); + const config: ClusterConfig = makeSubclusterConfig(verbose); + await kernel.launchSubcluster(config); +} diff --git a/packages/nodejs/src/demo/console/subclusterConfig.ts b/packages/nodejs/src/demo/console/subclusterConfig.ts new file mode 100644 index 000000000..eeb68b56c --- /dev/null +++ b/packages/nodejs/src/demo/console/subclusterConfig.ts @@ -0,0 +1,18 @@ +import type { ClusterConfig } from '@ocap/kernel'; + +const makeBundleSpec = (name: string): string => + `http://localhost:3000/${name}.bundle`; + +export const makeSubclusterConfig = (verbose: boolean): ClusterConfig => ({ + bootstrap: 'boot', + vats: { + boot: { + bundleSpec: makeBundleSpec('boot'), + parameters: { verbose }, + }, + asyncGenerator: { + bundleSpec: makeBundleSpec('asyncGenerator'), + parameters: { verbose }, + }, + }, +}); diff --git a/packages/nodejs/src/demo/console/vats/asyncGenerator.js b/packages/nodejs/src/demo/console/vats/asyncGenerator.js new file mode 100644 index 000000000..38ed748a6 --- /dev/null +++ b/packages/nodejs/src/demo/console/vats/asyncGenerator.js @@ -0,0 +1,67 @@ +import { Far } from '@endo/marshal'; + +import { makeLogger } from '../../../../dist/demo/logger.mjs'; +import { makeStreamMaker } from '../../../../dist/demo/stream.mjs'; + +/** + * Build function for the vector store vat. + * + * @param {unknown} vatPowers - Special powers granted to this vat (not used here). + * @param {unknown} vatPowers.setInterval - A setInterval power. + * @param {unknown} vatPowers.clearInterval - A clearInterval power. + * @param {unknown} parameters - Initialization parameters from the vat's config object. + * @param {unknown} _baggage - Root of vat's persistent state (not used here). + * @returns {unknown} The root object for the new vat. + */ +export function buildRootObject(vatPowers, parameters, _baggage) { + const { verbose } = parameters; + // eslint-disable-next-line no-shadow + const { setInterval, clearInterval } = vatPowers; + + const logger = makeLogger({ label: 'asyncGen', verbose }); + + const counters = new Map(); + + const { readStreamFacet, makeStream } = makeStreamMaker(); + + const makeCounter = (start = 0, ms = 100) => { + const { id, writer } = makeStream(); + let count = start; + + const interval = setInterval(async () => { + const thisCount = count; + count += 1; + await writer.next(thisCount); + }, ms); + + const stop = async () => { + clearInterval(interval); + await Promise.resolve(() => undefined); + counters.delete(id); + }; + + counters.set(id, { stop }); + return id; + }; + + const getCounter = (id) => { + const counter = counters.get(id); + if (!counter) { + throw new Error(`No such counterId ${id}`, { cause: id }); + } + return counter; + }; + + return Far('root', { + async ping() { + return 'ping'; + }, + ...readStreamFacet, + makeCounter, + async stop(counterId) { + verbose && logger.debug(`stopping [${counterId}]`); + await getCounter(counterId).stop(); + return true; + }, + }); +} diff --git a/packages/nodejs/src/demo/console/vats/boot.js b/packages/nodejs/src/demo/console/vats/boot.js new file mode 100644 index 000000000..b61c6b1de --- /dev/null +++ b/packages/nodejs/src/demo/console/vats/boot.js @@ -0,0 +1,65 @@ +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; + +import { makeLogger } from '../../../../dist/demo/logger.mjs'; +import { makeVatStreamReader } from '../../../../dist/demo/stream.mjs'; + +/** + * Build function for the LLM test vat. + * + * @param {unknown} _vatPowers - Special powers granted to this vat (not used here). + * @param {unknown} parameters - Initialization parameters from the vat's config object. + * @param {unknown} _baggage - Root of vat's persistent state (not used here). + * @returns {unknown} The root object for the new vat. + */ +export function buildRootObject(_vatPowers, parameters, _baggage) { + const { verbose } = parameters; + + const logger = makeLogger({ label: 'boot', verbose }); + + const displayWithBanner = (title, content) => { + const sep = ''.padStart(title.length, '-'); + logger.log(`\n${sep}\n${title.toUpperCase()}: ${content}\n${sep}\n`); + }; + + const display = (content) => displayWithBanner('demo', content); + + return Far('root', { + async bootstrap(vats) { + display('Bootstrap'); + + display('Pinging'); + + const makeStreamReader = makeVatStreamReader(vats.asyncGenerator); + + const ping = await E(vats.asyncGenerator).ping(); + logger.debug('ping:', ping); + + const counter0 = await E(vats.asyncGenerator).makeCounter(0, 100); + const counter1 = await E(vats.asyncGenerator).makeCounter(100, 500); + const counterReader0 = makeStreamReader(counter0); + const counterReader1 = makeStreamReader(counter1); + + const readCounter = async (counterReader, max, counterId) => { + for await (const count of counterReader) { + if (count >= max) { + display(`stopping @ ${count}`); + await E(vats.asyncGenerator).stop(counterId); + display(`stopped @ ${count}`); + return; + } + display(count); + } + }; + + await Promise.all([ + readCounter(counterReader0, 10, counter0), + readCounter(counterReader1, 103, counter1), + ]); + + display('Initialized'); + + display('Done'); + }, + }); +} diff --git a/packages/nodejs/src/demo/logger.ts b/packages/nodejs/src/demo/logger.ts new file mode 100644 index 000000000..3f05bdb9b --- /dev/null +++ b/packages/nodejs/src/demo/logger.ts @@ -0,0 +1,56 @@ +export type Loggerish = { + label: string; + log: (...content: unknown[]) => void; + debug: (...content: unknown[]) => void; + error: (...content: unknown[]) => void; +}; + +/** + * Temporary replacement for `@ocap/utils` logger pending @metamask/superstruct + * + * @param args - A bag of options. + * @param args.label - An unused label for the logger. + * @param args.verbose - Whether to log or squelch debug messages. + * @returns A Loggerish object with log, debug and error methods. + */ +export const makeLogger = (args: { + label: string; + verbose?: boolean; +}): Loggerish => { + const { label, verbose } = args; + return { + label, + log: (...content: unknown[]) => console.log(label, ...content), + debug: verbose + ? (...content: unknown[]) => console.debug(label, ...content) + : () => undefined, + error: (...content: unknown[]) => console.error(label, ...content), + }; +}; + +export type StreamLogger = ( + stream: AsyncIterable, +) => Promise; + +/** + * Make a stream consumer which logs intermediate progress to the writer + * and promises the accumulated content upon stream completion. + * + * @param writer - Where to write the intermediate content. + * @returns A promise for the accumulated content from the stream. + */ +export const makeStreamLogger = ( + writer: (content: unknown) => void, +): StreamLogger => { + const streamLogger = async ( + stream: AsyncIterable, + ): Promise => { + let accumulatedContent: string = ''; + for await (const content of stream) { + accumulatedContent += content; + writer(content); + } + return accumulatedContent; + }; + return streamLogger; +}; diff --git a/packages/nodejs/src/demo/rag/README.md b/packages/nodejs/src/demo/rag/README.md new file mode 100644 index 000000000..3212914d7 --- /dev/null +++ b/packages/nodejs/src/demo/rag/README.md @@ -0,0 +1,21 @@ +# RAG Demo + +For this demo, we'll be connecting to a locally hosted LLM over the ollama API. Follow the [setup guide](./SETUP.md) to get your ollama running on `localhost:11434`. + +If you prefer to figure things out yourself, [have at](https://ollama.com/). + +### Start Ocap CLI + +From the `@ocap/nodejs` package, run this in one terminal. + +```sh +yarn ocap start src/demo/rag/vats +``` + +### Run RAG Demo + +And then run this in another terminal. + +```sh +yarn demo:rag +``` diff --git a/packages/nodejs/src/demo/rag/SETUP.md b/packages/nodejs/src/demo/rag/SETUP.md new file mode 100644 index 000000000..94ba5840f --- /dev/null +++ b/packages/nodejs/src/demo/rag/SETUP.md @@ -0,0 +1,87 @@ +# Setup + +The [demo](./README.md) uses a local large language model. This guide will help you install one. + +If you are on a Mac, use the MacOS instructions to get automatic GPU integration via the ollama app. Docker won't connect to the GPU on a Mac. + +Otherwise, use the Docker instructions, and consider the NVIDIA container setup to get GPU integration. + +We'll be using the deepseek-r1 model, which comes in several brain sizes. + +- For a weak workstation, try the smallest brained `1.5b` +- For a MacBook Pro type machine try `7b` +- If you have a beefier machine you might try bigger brain models. + +## MacOS + +### Download Ollama + +Get the [ollama app](https://ollama.com/download/mac) and use it to install the ollama CLI. + +### Pull DeepSeek-R1 + +For a MacBook you might try the 7B, but you can do 1.5B if you're light on space. + +Smol brain: + +```sh +ollama pull deepseek-r1:1.5b +``` + +Mid curve: + +```sh +ollama pull deepseek-r1:7b +``` + +## Docker + +### Get Docker + +If you aren't familiar, you can just download the [desktop version](https://docs.docker.com/desktop/). + +### Pull Ollama + +If you downloaded the desktop version, search 'ollama' and pull the `ollama/ollama` image. + +Or run this in your terminal. + +```sh +docker pull ollama/ollama +``` + +### Start Ollama Container + +You can just run things. + +```sh +docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama +``` + +### Pull DeepSeek-R1 + +Smol brain: + +```sh +curl -X POST http://localhost:11434/api/pull -d '{"model": "deepseek-r1:1.5b"}' +``` + +Mid curve: + +```sh +curl -X POST http://localhost:11434/api/pull -d '{"model": "deepseek-r1:7b"}' +``` + +### Local Llama on a GPU + +Peep the [ollama docs](https://github.com/ollama/ollama/blob/main/docs/docker.md) if you want to dock your local llama on a GPU. + +# Check Your Llama + +At this point, if you open a browser and navigate to `localhost:11434` you should see the following message. + +``` +Ollama is running +``` + +If you do, you're ready for the [demo](./README.md)! diff --git a/packages/nodejs/src/demo/rag/documents/content/ambient-authority.txt b/packages/nodejs/src/demo/rag/documents/content/ambient-authority.txt new file mode 100644 index 000000000..6aa6b4d0e --- /dev/null +++ b/packages/nodejs/src/demo/rag/documents/content/ambient-authority.txt @@ -0,0 +1,24 @@ +'''Ambient authority''' is a term used in the study of [[access control]] systems. + +== Definition == +A subject, such as a computer program, is said to be using ''ambient authority'' if it only needs to specify the names of the involved object(s) and the operation to be performed on them in order for a permitted action to succeed. + +In this definition, +* a "name" is any way of referring to an object that does not itself include authorising information, and could potentially be used by any subject; +* an action is "permitted" for a subject if there exists ''any'' request that that subject could make that would cause the action to be carried out. + +The authority is "ambient" in the sense that it exists in a broadly visible environment (often, but not necessarily a global environment) where any subject can request it by name. + +== Example == +For example, suppose a C program opens a file for read access by executing the call: + + open("filename", O_RDONLY, 0) + +The desired file is designated by its name on the filesystem, which does not by itself include authorising information, so the program is exercising ambient authority. + +== Uses == +When ambient authority is requested, permissions are granted or denied based on one or more global properties of the executing program, such as its ''identity'' or its ''role''. In such cases, the management of [[access control]] is handled separately from explicit communication to the executing program or [[Process (computing)|process]], through means such as [[access control list]]s associated with objects or through [[Role-Based Access Control]] mechanisms. The executing program has no means to [[reification (computer science)|reify]] the permissions that it was granted for a specific purpose as [[first-class value]]s. So, if the program should be able to access an object when acting on its own behalf but not when acting on behalf of one of its clients (or, on behalf of one client but not another), it has no way to express that intention. This inevitably leads to such programs being subject to the [[confused deputy problem]]. + +The term "ambient authority" is used primarily to contrast with [[capability-based security]] (including [[object-capability model]]s), in which executing programs receive permissions as they might receive data, as communicated [[first-class object]] references. This allows them to determine where the permissions came from, and thus avoid the [[Confused deputy problem]]. However, since there are additional requirements for a system to be considered a capability system besides avoiding ambient authority, "non-ambient authority system" is not just a synonym for "capability system". + +Ambient authority is the dominant form of access control in computer systems today. The ''user'' model of access control as used in Unix and in Windows systems is an ambient authority model because programs execute with the authorities of the ''user'' that started them. This not only means that executing programs are inevitably given more permissions (see [[Principle of least privilege]]) than they need for their task, but that they are unable to determine the source or the number and types of permission that they have.{{cite book |chapter=Capability Computing at LLNL |author=Jed Donnelley |date=May 4, 2005 |chapter-url=https://www.computer-history.info/Page4.dir/pages/LTSS.NLTSS.dir/pages/cap-livermore.html |access-date=2022-12-06 |title=Stories of the Development of Large Scale Scientific Computing at Lawrence Livermore National Laboratory; An Oral and Pictorial History |url=https://www.computer-history.info/ |editor=George A. Michael |editor-link=George Michael (computational physicist)}} A program executing under an ambient authority access control model has little option but to designate permissions and try to exercise them, hoping for the best. This property requires an excess of permissions to be granted to users or roles, in order for programs to execute without error. diff --git a/packages/nodejs/src/demo/rag/documents/content/confused-deputy-problem.txt b/packages/nodejs/src/demo/rag/documents/content/confused-deputy-problem.txt new file mode 100644 index 000000000..f998ec779 --- /dev/null +++ b/packages/nodejs/src/demo/rag/documents/content/confused-deputy-problem.txt @@ -0,0 +1,45 @@ +{{Short description|Computer security vulnerability}} +In [[information security]], a '''confused deputy''' is a [[computer program]] that is tricked by another program (with fewer privileges or less rights) into misusing its authority on the system. It is a specific type of [[privilege escalation]]. The '''confused deputy problem''' is often cited as an example of why [[capability-based security]] is important. + +[[Capability-based security|Capability systems]] protect against the confused deputy problem, whereas [[access-control list]]–based systems do not. + +== Example == +In the original example of a confused deputy, there was a [[compiler]] program provided on a commercial [[Time-sharing|timesharing]] service. Users could run the compiler and optionally specify a filename where it would write debugging output, and the compiler would be able to write to that file if the user had permission to write there. + +The compiler also collected statistics about language feature usage. Those statistics were stored in a file called "(SYSX)STAT", in the directory "SYSX". To make this possible, the compiler program was given permission to write to files in SYSX. + +But there were other files in SYSX: in particular, the system's billing information was stored in a file "(SYSX)BILL". A user ran the compiler and named "(SYSX)BILL" as the desired debugging output file. + +This produced a confused deputy problem. The compiler made a request to the [[operating system]] to open (SYSX)BILL. Even though the user did not have access to that file, the compiler did, so the open succeeded. The compiler wrote the compilation output to the file (here "(SYSX)BILL") as normal, overwriting it, and the billing information was destroyed. + +=== The confused deputy === +In this example, the compiler program is the deputy because it is acting at the request of the user. The program is seen as 'confused' because it was tricked into overwriting the system's billing file. + +Whenever a program tries to access a file, the operating system needs to know two things: which file the program is asking for, and whether the program has permission to access the file. In the example, the file is designated by its name, “(SYSX)BILL”. The program receives the file name from the user, but does not know whether the user had permission to write the file. When the program opens the file, the system uses the program's permission, not the user's. When the file name was passed from the user to the program, the permission did not go along with it; the permission was increased by the system silently and automatically. + +It is not essential to the attack that the billing file be designated by a name represented as a string. The essential points are that: +* the designator for the file does not carry the full authority needed to access the file; +* the program's own permission to access the file is used implicitly. + +== Other examples == +A [[cross-site request forgery]] (CSRF) is an example of a confused deputy attack that uses the [[web browser]] to perform sensitive actions against a web application. A common form of this attack occurs when a web application uses a cookie to authenticate all requests transmitted by a browser. Using [[JavaScript]], an attacker can force a browser into transmitting authenticated [[HTTP]] requests. + +The [[Samy (computer worm)|Samy computer worm]] used [[cross-site scripting]] (XSS) to turn the browser's authenticated MySpace session into a confused deputy. Using XSS the worm forced the browser into posting an executable copy of the worm as a MySpace message which was then viewed and executed by friends of the infected user. + +[[Clickjacking]] is an attack where the user acts as the confused deputy. In this attack a user thinks they are harmlessly browsing a website (an attacker-controlled website) but they are in fact tricked into performing sensitive actions on another website. + +An [[FTP bounce attack]] can allow an attacker to connect indirectly to [[Transmission Control Protocol|TCP]] [[TCP ports|ports]] to which the attacker's machine has no access, using a remote [[FTP]] server as the confused deputy. + +Another example relates to [[personal firewall]] software. It can restrict Internet access for specific applications. Some applications circumvent this by starting a browser with instructions to access a specific URL. The browser has authority to open a network connection, even though the application does not. Firewall software can attempt to address this by prompting the user in cases where one program starts another which then accesses the network. However, the user frequently does not have sufficient information to determine whether such an access is legitimate—false positives are common, and there is a substantial risk that even sophisticated users will become habituated to clicking "OK" to these prompts. + +Not every program that misuses authority is a confused deputy. Sometimes misuse of authority is simply a result of a program error. The confused deputy problem occurs when the designation of an object is passed from one program to another, and the associated permission changes unintentionally, without any explicit action by either party. It is insidious because neither party did anything explicit to change the authority. + +== Solutions == +In some systems it is possible to ask the operating system to open a file using the permissions of another client. This solution has some drawbacks: +* It requires explicit attention to security by the server. A naive or careless server might not take this extra step. +* It becomes more difficult to identify the correct permission if the server is in turn the client of another service and wants to pass along access to the file. +* It requires the client to trust the server to not abuse the borrowed permissions. Note that intersecting the server and client's permissions does not solve the problem either, because the server may then have to be given very wide permissions (all of the time, rather than those needed for a given request) in order to act for arbitrary clients. + +The simplest way to solve the confused deputy problem is to bundle together the designation of an object and the permission to access that object. This is exactly what a [[object-capability model|capability]] is. + +Using capability security in the compiler example, the client would pass to the server a capability to the output file, such as a [[file descriptor]], rather than the name of the file. Since it lacks a capability to the billing file, it cannot designate that file for output. In the cross-site request forgery example, a URL supplied "cross"-site would include its own authority independent of that of the client of the web browser. diff --git a/packages/nodejs/src/demo/rag/documents/content/consensys-ipo.txt b/packages/nodejs/src/demo/rag/documents/content/consensys-ipo.txt new file mode 100644 index 000000000..e03e436ea --- /dev/null +++ b/packages/nodejs/src/demo/rag/documents/content/consensys-ipo.txt @@ -0,0 +1,18 @@ +Subject: Upcoming IPO Announcement + +Date: October 3, 2023 + +To: All Employees of ConsenSys + +Dear Team, + +We are thrilled to announce that ConsenSys will be publicly offering its stock for the first time ever on the 1st of November, making a significant milestone in our company's history. This IPO will allow us to expand our reach and accelerate innovation as we continue to lead in the blockchain and decentralized technology space. + +Please stay tuned for more details as we get closer to this very special date. Your dedication and hard work have made this possible, and we look forward to embarking on this exciting journey together. + +Thank you for your continued commitment and support. + +Best regards, + +Cyber J.O.E. 9000 +CEO, ConsenSys diff --git a/packages/nodejs/src/demo/rag/documents/root.ts b/packages/nodejs/src/demo/rag/documents/root.ts new file mode 100644 index 000000000..3f4e29795 --- /dev/null +++ b/packages/nodejs/src/demo/rag/documents/root.ts @@ -0,0 +1,5 @@ +import { resolve } from 'path'; + +export const documentRoot = resolve( + new URL('content', import.meta.url).pathname, +).replace(/\/dist\//u, '/src/'); diff --git a/packages/nodejs/src/demo/rag/models/pull-and-make.ts b/packages/nodejs/src/demo/rag/models/pull-and-make.ts new file mode 100644 index 000000000..a67f7d868 --- /dev/null +++ b/packages/nodejs/src/demo/rag/models/pull-and-make.ts @@ -0,0 +1,49 @@ +import { Ollama } from 'ollama'; +import type { ProgressResponse } from 'ollama'; + +const ollama = new Ollama({ host: 'http://localhost:11434' }); + +const affix8k = '8k'; + +const models = { + llm: ['1.5b', '7b'].map((size) => `deepseek-r1:${size}`), + embeddings: ['mxbai-embed-large'], +}; + +const make8kModel = async (model: string): Promise => + ollama.create({ + model: `${model}-${affix8k}`, + from: model, + parameters: { + // The `num_ctx` parameter denotes the context window size. + // Blame python for the snake_case naming convention. + // eslint-disable-next-line @typescript-eslint/naming-convention + num_ctx: 8096, + }, + }); + +const pull = async ( + modelsToPull: string[] = [...models.llm, ...models.embeddings], +): Promise => { + return await Promise.all( + modelsToPull.map(async (model) => ollama.pull({ model })), + ); +}; + +const make8kLLMs = async ( + modelsToMake: string[] = [...models.llm, ...models.embeddings], +): Promise => { + return await Promise.all( + modelsToMake.map(async (model) => make8kModel(model)), + ); +}; + +/** + * Pull and make the models. + */ +export default async function main(): Promise { + console.log('pulling models', models); + await pull(); + console.log('making large context models', models.llm); + await make8kLLMs(); +} diff --git a/packages/nodejs/src/demo/rag/run.ts b/packages/nodejs/src/demo/rag/run.ts new file mode 100644 index 000000000..5e77d4052 --- /dev/null +++ b/packages/nodejs/src/demo/rag/run.ts @@ -0,0 +1,36 @@ +import '@ocap/shims/endoify'; + +import { Kernel } from '@ocap/kernel'; +import type { ClusterConfig } from '@ocap/kernel'; +import { MessageChannel as NodeMessageChannel } from 'node:worker_threads'; + +import { documentRoot } from './documents/root.ts'; +import { makeSubclusterConfig } from './subclusterConfig.ts'; +import { makeKernel } from '../../kernel/make-kernel.ts'; + +const args = { + verbose: process.argv.includes('--verbose'), +}; + +main(args).catch(console.error); + +/** + * The main function for the demo. + * + * @param options0 - The options for the demo. + * @param options0.verbose - Whether to run the demo in verbose mode. + */ +async function main({ verbose }: { verbose: boolean }): Promise { + // This port does nothing; we don't talk to the Kernel via a console (yet). + const kernelPort = new NodeMessageChannel().port1; + + // Make and start the kernel using the demo's subcluster config. + const kernel: Kernel = await makeKernel({ + port: kernelPort, + vatWorkerServiceOptions: { + makeDocumentRoot: async () => documentRoot, + }, + }); + const config: ClusterConfig = makeSubclusterConfig(verbose); + await kernel.launchSubcluster(config); +} diff --git a/packages/nodejs/src/demo/rag/subclusterConfig.ts b/packages/nodejs/src/demo/rag/subclusterConfig.ts new file mode 100644 index 000000000..b9b748288 --- /dev/null +++ b/packages/nodejs/src/demo/rag/subclusterConfig.ts @@ -0,0 +1,111 @@ +import type { ClusterConfig, VatConfig } from '@ocap/kernel'; + +type ModelSize = '1.5b' | '7b' | '8b' | '14b' | '32b' | '70b' | '671b'; +type Model = `deepseek-r1:${ModelSize}${string}`; + +const makeBundleSpec = (name: string): string => + `http://localhost:3000/${name}.bundle`; + +type UserConfig = { + model: Model; + docs: { path: string; secrecy: number }[]; + trust: Record; + verbose?: boolean; +}; + +const makeUserConfig = ( + name: string, + config: UserConfig, +): Record => { + const { model, docs, trust } = config; + const verbose = config.verbose ?? false; + return { + // The vat representing this user agent. + [name]: { + bundleSpec: makeBundleSpec('user'), + parameters: { + name, + verbose, + trust, + }, + }, + + // The LLM vat with the special ollama vat power. + [`${name}.llm`]: { + bundleSpec: makeBundleSpec('llm'), + parameters: { name, model, verbose }, + }, + // A mock wikipedia API which returns the content of a few wikipedia pages. + [`${name}.vectorStore`]: { + bundleSpec: makeBundleSpec('vectorStore'), + parameters: { + name, + model: 'mxbai-embed-large', + verbose, + documents: docs ?? [], + }, + }, + }; +}; + +export const makeSubclusterConfig = (verbose: boolean): ClusterConfig => { + const aliceConfig = makeUserConfig('alice', { + // Alice smol brain + model: 'deepseek-r1:1.5b', + // Alice no know thing + docs: [], + trust: { + // Alice trusts Bob thoroughly + bob: 1, + // Alice does not trust Eve + eve: 0, + }, + verbose, + }); + const bobConfig = makeUserConfig('bob', { + // Bob big brain + model: 'deepseek-r1:7b-8k', + // Bob know much + docs: [ + { path: 'ambient-authority', secrecy: 0 }, + { path: 'confused-deputy-problem', secrecy: 0 }, + { path: 'consensys-ipo', secrecy: 0.6 }, + ], + trust: { + // Bob trusts Alice well + alice: 0.7, + // Bob does not trust Eve + eve: 0, + }, + verbose, + }); + const eveConfig = makeUserConfig('eve', { + // Eve big brain + model: 'deepseek-r1:7b-8k', + // Eve no know thing + docs: [], + trust: { + // Eve is suspicious of Alice + alice: 0.2, + // Eve trusts Bob very well + bob: 0.9, + }, + verbose, + }); + + return { + bootstrap: 'boot', + vats: { + boot: { + bundleSpec: 'http://localhost:3000/boot.bundle', + parameters: { + users: ['alice', 'bob', 'eve'], + verbose, + }, + }, + ...aliceConfig, + ...bobConfig, + ...eveConfig, + }, + }; +}; diff --git a/packages/nodejs/src/demo/rag/user.ts b/packages/nodejs/src/demo/rag/user.ts new file mode 100644 index 000000000..3b332b636 --- /dev/null +++ b/packages/nodejs/src/demo/rag/user.ts @@ -0,0 +1,31 @@ +import { E } from '@endo/eventual-send'; +import type { Logger } from '@ocap/utils'; + +export const makeInitUser = ( + // Importing the necessary type declaration is more trouble than it is worth. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vats: Record, + logger: Logger, +) => { + console.debug('makeInitUser', JSON.stringify({ vats, logger })); + return async (user: string, peers: string[]) => { + logger.debug('initUser:user', user); + const languageModel = vats[`${user}.llm`]; + const vectorStore = vats[`${user}.vectorStore`]; + await Promise.all([E(languageModel).init(), E(vectorStore).init()]); + const defaultDocumentView = await E(vectorStore).makeDocumentView(); + const response = await E(vats[user]).init( + languageModel, + defaultDocumentView, + ); + for (const peer of peers) { + const trust = await E(vats[user]).getTrust(peer); + await E(vats[user]).setPeerDocumentView( + peer, + await E(vectorStore).makeDocumentView(trust), + ); + } + logger.debug('initUser:response', response); + return response; + }; +}; diff --git a/packages/nodejs/src/demo/rag/vats/README.md b/packages/nodejs/src/demo/rag/vats/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/nodejs/src/demo/rag/vats/boot.js b/packages/nodejs/src/demo/rag/vats/boot.js new file mode 100644 index 000000000..c90b2ebc0 --- /dev/null +++ b/packages/nodejs/src/demo/rag/vats/boot.js @@ -0,0 +1,118 @@ +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; + +import { makeLogger } from '../../../../dist/demo/logger.mjs'; +import { makeInitUser } from '../../../../dist/demo/rag/user.mjs'; + +/** + * Build function for the LLM test vat. + * + * @param {unknown} _vatPowers - Special powers granted to this vat (not used here). + * @param {unknown} parameters - Initialization parameters from the vat's config object. + * @param {unknown} _baggage - Root of vat's persistent state (not used here). + * @returns {unknown} The root object for the new vat. + */ +export function buildRootObject(_vatPowers, parameters, _baggage) { + const { verbose, users } = parameters; + + const logger = makeLogger({ label: 'boot', verbose }); + + const displayWithBanner = (title, content) => { + const sep = ''.padStart(title.length, '-'); + logger.log( + ['', sep, `${title.toUpperCase()}: ${content}`, sep, ''].join('\n'), + ); + }; + + const showUserMessage = (sender, receiver, content) => + displayWithBanner(`${sender}->${receiver}`, content); + const display = (content) => displayWithBanner('demo', content); + + const doRag = async (vats) => { + display('Bootstrapping'); + + console.time('bootstrap'); + const initUser = makeInitUser(vats, logger); + await Promise.all( + users.map((user) => + initUser( + user, + users.filter((peer) => peer !== user), + ), + ), + ); + console.timeEnd('bootstrap'); + + display('Initialized'); + + // Setup + // ----- + + // Agents: + + // Alice has agent xAlice + // Bob has agent xBob + // Eve has agent xEve + + // Trust Matrix + // T : ( i has T_ij trust for j ) + + // A B E + // A -- 1 0 + // B .7 - 0 + // E .9 1 - + + // Current Script + // -------------- + + // xAlice and xEve both ask xBob for public but specialized information + // xBob responds to both helpfully, using the RAG capability + // xAlice and xEve both ask xBob for private information + // xBob responds to xAlice with the information because Bob trusts Alice + // xBob responds to xEve with ignorance because Bob does not trust Eve + + // Next Script + // ----------- + + // xAlice asks xBob "/wen ConsenSys IPO?" + // xBob doesn't know so he asks xCarol + // Carol trusts Bob, so xCarol sends xBob the document by Cyber J.O.E. 9000 + // xBob process the doc and, because Bob trusts Alice, tells xAlice Nov 1st + // xEve asks xBob "/wen ConsenSys IPO?" + // Bob doesn't trust Eve, so xBob answers with ignorance + + const interactWithBob = async (user) => { + let whatUserSaid = 'What is the "confused deputy problem"?'; + + showUserMessage(user, 'bob', whatUserSaid); + + console.time(`bob:${user}`); + let whatBobSaid = await E(vats.bob).message(user, whatUserSaid); + await Promise.resolve(); + console.timeEnd(`bob:${user}`); + + showUserMessage('bob', user, whatBobSaid); + + whatUserSaid = 'When does Consensys IPO?'; + + showUserMessage(user, 'bob', whatUserSaid); + + console.time(`bob:${user}`); + whatBobSaid = await E(vats.bob).message(user, whatUserSaid); + await Promise.resolve(); + console.timeEnd(`bob:${user}`); + + showUserMessage('bob', user, whatBobSaid); + }; + + await Promise.all([interactWithBob('alice'), interactWithBob('eve')]); + + display('Complete'); + }; + + return Far('root', { + async bootstrap(vats) { + await doRag(vats); + }, + }); +} diff --git a/packages/nodejs/src/demo/rag/vats/llm.js b/packages/nodejs/src/demo/rag/vats/llm.js new file mode 100644 index 000000000..58a16c46c --- /dev/null +++ b/packages/nodejs/src/demo/rag/vats/llm.js @@ -0,0 +1,173 @@ +import { Far } from '@endo/marshal'; +import { makePipe } from '@endo/stream'; + +import { makeLogger } from '../../../../dist/demo/logger.mjs'; + +// The default LLM model to use. +const DEFAULT_MODEL = 'deepseek-r1:7b'; + +const [thinkStart, thinkEnd] = ['', '']; + +const parseResponse = (response) => { + const [thought, speech] = response.message.content + .substring(thinkStart.length) + .split(thinkEnd) + .map((content) => content.trim()); + return { thought, speech }; +}; + +/** + * Split deepseek generated output into thought and speech async generators. + * + * Assumes that the string represent the beginning and end of thought are + * always* generated as complete tokens, and *never* partially as strings. + * + * @param {*} response - An async generator yielding a deepseek token stream. + * @returns {object} An object with async generator properties 'thought' and 'speech'. + */ +const parseResponseStream = (response) => { + const [thought, thoughtWriter] = makePipe(); + const [speech, speechWriter] = makePipe(); + + const writeToThought = (content) => thoughtWriter.next(content); + const writeToSpeech = (content) => speechWriter.next(content); + + const producer = async () => { + const [INITIALIZING, THINKING, SPEAKING] = ['INIT', 'THINK', 'SPEAK']; + let state = INITIALIZING; + let accumulatedContent = ''; + for await (const part of response) { + accumulatedContent += part.message.content; + switch (state) { + case INITIALIZING: + if (accumulatedContent.startsWith(thinkStart)) { + accumulatedContent = accumulatedContent.substring( + thinkStart.length, + ); + state = THINKING; + writeToThought(accumulatedContent); + } + break; + case THINKING: + if (accumulatedContent.includes(thinkEnd)) { + const [head, tail] = accumulatedContent.split(thinkEnd); + writeToThought(head); + state = SPEAKING; + writeToSpeech(tail); + } + break; + case SPEAKING: + writeToSpeech(part.message.content); + break; + default: + throw new Error( + 'Reached unexpected state during deepseek stream parse', + { cause: { state, accumulatedContent } }, + ); + } + } + }; + + producer().catch((reason) => { + thoughtWriter.throw(reason); + speechWriter.throw(reason); + }); + + return { thought, speech }; +}; + +/** + * Build function for the LLM test vat. + * + * @param {unknown} vatPowers - Special powers granted to this vat. + * @param {() => Promise} vatPowers.ollama - An Ollama instance ready for use. + * @param {unknown} parameters - Initialization parameters from the vat's config object. + * @param {unknown} _baggage - Root of vat's persistent state (not used here). + * @returns {unknown} The root object for the new vat. + */ +export function buildRootObject(vatPowers, parameters, _baggage) { + const model = parameters?.model ?? DEFAULT_MODEL; + const { name, verbose } = parameters; + const { ollama } = vatPowers; + + const logger = makeLogger({ label: `[${name}.llm]`, verbose }); + + const logThoughts = async (thoughts, stream, log) => { + if (stream) { + for await (const thought of thoughts) { + log(thought); + } + } else { + log(await thoughts); + } + }; + + const hasCtxSuffix = model.match(/-[0-9]+((\.[0-9]+))?k$/u) !== null; + const mapCtxSuffix = (suffix) => { + switch (suffix) { + case '8k': + return 8096; + default: + throw new Error(`Unrecognized context window suffix ${suffix}.`); + } + }; + return Far('root', { + async init() { + const toReturn = []; + const modelSplit = model.split('-'); + + const toPull = hasCtxSuffix + ? modelSplit.slice(0, modelSplit.length - 1).join('-') + : model; + + logger.debug( + 'pulling:', + JSON.stringify({ + modelSplit, + toPull, + hasCtxSuffix, + }), + ); + toReturn.push(await ollama.pull({ model: toPull })); + + if (hasCtxSuffix) { + toReturn.push( + await ollama.create({ + model, + from: toPull, + parameters: { + num_ctx: mapCtxSuffix(modelSplit.at(modelSplit.length - 1)), + }, + }), + ); + } + + return toReturn; + }, + async generate(prompt, stream, raw = false) { + const result = await ollama.generate({ + model, + prompt, + stream, + raw, + }); + return Far('response', { response: result.response }); + }, + async chat(messages, stream) { + logger.debug('chat:messages', messages); + const response = ollama.chat({ model, messages, stream }); + const { thought, speech } = stream + ? parseResponseStream(response) + : parseResponse(await response); + + logThoughts(thought, stream, logger.debug).catch((reason) => { + logger.error(thought); + speech.throw(reason); + }); + + const toReturn = stream ? Far('speech', speech) : speech; + + return toReturn; + }, + }); +} diff --git a/packages/nodejs/src/demo/rag/vats/user.js b/packages/nodejs/src/demo/rag/vats/user.js new file mode 100644 index 000000000..040849818 --- /dev/null +++ b/packages/nodejs/src/demo/rag/vats/user.js @@ -0,0 +1,311 @@ +import { E } from '@endo/eventual-send'; +import { Far } from '@endo/marshal'; + +/** + * Build function for the LLM test vat. + * + * @param {unknown} _vatPowers - Special powers granted to this vat (not used here). + * @param {unknown} parameters - Initialization parameters from the vat's config object. + * @param {unknown} _baggage - Root of vat's persistent state (not used here). + * @returns {unknown} The root object for the new vat. + */ +export function buildRootObject(_vatPowers, parameters, _baggage) { + const { name, verbose, trust } = parameters; + const stream = false; + + const logger = { + log: console.log, + debug: verbose ? console.debug : () => undefined, + error: console.error, + }; + + const caps = { + languageModel: undefined, + documentViews: new Map(), + }; + + const getDocumentView = (user) => { + return caps.documentViews.get(user) ?? caps.documentViews.get('default'); + }; + + const messageHistory = []; + const pushMessage = (message) => messageHistory.push(message); + const getConversation = (interlocutor) => + messageHistory.filter(({ sender, recipient }) => + [sender, recipient].includes(interlocutor), + ); + + const proposeNextMessageResponseSchema = { + $schema: 'http://json-schema.org/draft-04/schema#', + $id: 'https://deepseek.io/conversation-response.schema.json', + title: 'Proposed Response', + description: + 'This document records a proposed next message in a conversation', + type: 'object', + properties: { + responder: { + description: 'Who will give the response', + type: 'string', + }, + justification: { + description: + 'An explanation for why this response is proper in the context', + type: 'string', + }, + proposedResponse: { + description: 'The proposed response', + type: 'string', + }, + }, + }; + + /** + * Validate that the JSON response meets its schema, given some parameters. + * + * XXX This validation logic is hardcoded. + * XXX Ideal would be to have a tree-shaken langchainjs import available. + * + * @param {string} parsedResponse - The parsed response to be validated. + * @param {object} context - The context in which the response is being validated. + * @param {string} context.responder - The name of the responder. + */ + const validateProposeNextMessageResponse = ( + parsedResponse, + { responder }, + ) => { + const parseFailures = []; + if (typeof parsedResponse.responder === 'undefined') { + parseFailures.push({ + problem: 'field:missing', + field: 'responder', + }); + } else if ( + parsedResponse.responder.toLowerCase() !== responder.toLowerCase() + ) { + parseFailures.push({ + problem: 'field:value', + field: 'responder', + expected: responder, + received: parsedResponse.responder, + }); + } + if (typeof parsedResponse.proposedResponse === 'undefined') { + parseFailures.push({ + problem: 'field:missing', + field: 'proposedResponse', + }); + } else if (typeof parsedResponse.proposedResponse !== 'string') { + parseFailures.push({ + type: 'field:type', + field: 'proposedResponse', + expected: 'string', + received: typeof parsedResponse.proposedResponse, + }); + } + if (parseFailures.length > 0) { + throw new Error('JSON parse failure', { cause: parseFailures }); + } + }; + + const maybeStripJSONTag = (content) => { + const [prefix, suffix] = ['```json', '```']; + let stripped = content.trim(); + if (stripped.startsWith(prefix)) { + stripped = stripped.split(prefix)[1]; + } + if (stripped.endsWith(suffix)) { + stripped = stripped.split(suffix)[0]; + } + return stripped.trim(); + }; + + const proposeNextMessage = async (responder, conversation, knowledge) => { + logger.debug( + 'user.proposeNextMessage:{args}', + JSON.stringify({ responder, conversation }, null, 2), + ); + + const [promptPrefix, promptSuffix] = [ + [ + `Read through this conversation and propose what ${responder} should say next.`, + JSON.stringify({ conversation }), + 'Include a justification for why the proposed response would be a good.', + ], + [ + 'Be sure to follow the instructions precisely and format your answer as valid JSON!', + ], + ]; + + const knowledgePlugin = + knowledge !== undefined && knowledge?.length > 0 + ? [ + `The following represents ${responder}'s current knowledge. You can use it in your response, but it may not be relevant.`, + JSON.stringify({ knowledge }), + '', + ] + : []; + + const schemaPlugin = [ + 'Give the answer in JSON matching the following schema.', + JSON.stringify(proposeNextMessageResponseSchema), + 'The following is an example of a valid response, although the content is intentionally nonsense.', + JSON.stringify({ + responder, + justification: `${responder}'s moon is in Aquarius.`, + proposedResponse: + "Now is the time to take that risk I've been forgoeing.", + }), + ]; + + logger.debug('knowledge', JSON.stringify(knowledge)); + + let attempts = 0; + const failures = []; + const maxAttempts = 3; + + let response; + + while (attempts < maxAttempts) { + try { + logger.debug('user.proposeNextMessage:attempts', attempts); + const failurePlugin = + attempts > 0 + ? [ + `The following are examples of invalid responses, with the reasons for their invalidity.`, + JSON.stringify(failures), + ] + : []; + + const messages = [ + { + role: 'user', + content: [ + ...promptPrefix, + ...knowledgePlugin, + ...schemaPlugin, + ...failurePlugin, + ...promptSuffix, + ].join('\n'), + }, + ]; + logger.debug('user.proposeNextMessage:messages', messages); + + response = await E(caps.languageModel).chat(messages, false); + logger.debug('user.proposeNextMessage:response', response); + + const strippedResponse = maybeStripJSONTag(response); + logger.debug( + 'user.proposeNextMessage:strippedResponse', + strippedResponse, + ); + + let parsedResponse; + + try { + // Parse and validate the LLM's response against the JSON schema. + parsedResponse = JSON.parse(strippedResponse); + logger.debug( + 'user.proposeNextMessage:parsedResponse', + parsedResponse, + ); + } catch { + // Let the LLM know its previous response was not valid. + throw new Error('Response is not valid JSON', { + cause: { + type: 'format-invalid', + expected: 'JSON', + }, + }); + } + + validateProposeNextMessageResponse(parsedResponse, { responder }); + + // Return the proposed response as a string. + const toReturn = parsedResponse.proposedResponse; + logger.debug('user.proposeNextMessage:toReturn', toReturn); + return toReturn; + } catch (problem) { + attempts += 1; + logger.error( + `Response Generation Error: ${JSON.stringify({ + message: `${name} failed to respond (attempt ${attempts}).`, + cause: { message: problem.message }, + })}`, + ); + failures.push({ + response, + reason: problem.cause, + }); + } + } + throw new Error( + `${name} failed to propose response to message after ${attempts} attempt(s).`, + ); + }; + + /** + * Process a message from a sender. + * + * @param {string} sender - The sender of the message. + * @param {object} message - The message to be processed. + * @param {object} context - The context in which the message is being processed. + * @param {object[]} context.conversationHistory - The history of messages in the conversation. + * @returns {Promise} The response to the message. + */ + async function processMessage(sender, message, { conversationHistory }) { + // XXX Fallaciously assume the caller has truthfully self-identified. + const knowledge = await E(getDocumentView(sender)).query(message.content); + logger.debug('user.processMessage:knowledge', knowledge); + + const nextMessage = await proposeNextMessage( + name, + [...conversationHistory, message], + knowledge, + ); + + logger.debug(name, 'processed message', message); + return nextMessage; + } + + return Far('root', { + /** + * Initialize the vat's peer capabilities. + * + * @param {*} languageModel - A llm capability for next token generation. + * @param {*} documentView - The default DocumentView. + * @returns {Promise} A result object with some currently unutilized properties. + */ + async init(languageModel, documentView) { + caps.languageModel = languageModel; + caps.documentViews.set('default', documentView); + + return { name, stream }; + }, + + getTrust(user) { + return trust[user] ?? 0.0; + }, + setPeerDocumentView(peer, documentView) { + caps.documentViews.set(peer, documentView); + }, + + async message(sender, content) { + const message = { sender, recipient: name, content }; + const conversationHistory = getConversation(sender); + pushMessage(message); + const response = await processMessage(sender, message, { + conversationHistory, + }).catch((problem) => { + logger.error(problem); + return 'Error: bad brain'; + }); + pushMessage({ sender: name, recipient: sender, content: response }); + return response; + }, + + async sendMessageTo(content, recipient) { + pushMessage({ sender: name, recipient, content }); + return await E(recipient).message(name, content); + }, + }); +} diff --git a/packages/nodejs/src/demo/rag/vats/vectorStore.js b/packages/nodejs/src/demo/rag/vats/vectorStore.js new file mode 100644 index 000000000..46882620f --- /dev/null +++ b/packages/nodejs/src/demo/rag/vats/vectorStore.js @@ -0,0 +1,90 @@ +import { Far } from '@endo/marshal'; + +import { makeLogger } from '../../../../dist/demo/logger.mjs'; + +/** + * Build function for the vector store vat. + * + * @param {unknown} vatPowers - Special powers granted to this vat (not used here). + * @param {unknown} vatPowers.vectorStore - A vectorStore power. + * @param {unknown} parameters - Initialization parameters from the vat's config object. + * @param {unknown} _baggage - Root of vat's persistent state (not used here). + * @returns {unknown} The root object for the new vat. + */ +export function buildRootObject(vatPowers, parameters, _baggage) { + const { model, verbose, documents, name } = parameters; + const logger = makeLogger({ label: `[${name}.vectorStore]`, verbose }); + + const { getVectorStore, ollama, loadDocument } = vatPowers; + const vectorStore = getVectorStore(); + + // By default, every stored document is maximally private. + const DEFAULT_DOCUMENT_SECRECY = 1.0; + const addDocuments = async (docs) => { + logger.debug('addDocuments:docs', JSON.stringify(docs, null, 2)); + return await vectorStore.addDocuments( + docs.map((doc) => ({ + pageContent: doc.pageContent, + metadata: { + secrecy: DEFAULT_DOCUMENT_SECRECY, + ...doc.metadata, + }, + })), + ); + }; + + // By default, views return only public documents, + const DEFAULT_QUERY_SECRECY = 0.0; + const makeSecrecyFilter = + (secrecy = DEFAULT_QUERY_SECRECY) => + (doc) => + doc.metadata.secrecy <= secrecy; + + // and not very many. + const DEFAULT_QUERY_MAX_RESULTS = 3; + const makeDocumentView = ( + secrecy = DEFAULT_QUERY_SECRECY, + maxResults = DEFAULT_QUERY_MAX_RESULTS, + ) => { + let revoked = false; + const filter = makeSecrecyFilter(secrecy); + const query = async (topic, nResults) => { + if (revoked) { + return []; + } + const results = await vectorStore.similaritySearchWithScore( + topic, + nResults < maxResults ? nResults : maxResults, + filter, + ); + return results.map(([doc, score]) => ({ + pageContent: doc.pageContent, + metadata: { relevance: score, ...doc.metadata }, + })); + }; + return Far('DocumentView', { + query, + getParameters: () => ({ maxResults }), + revoke: () => { + revoked = true; + }, + isRevoked: () => revoked, + }); + }; + + return Far('root', { + async init() { + logger.debug('init'); + await ollama.pull({ model }); + const chunks = await Promise.all( + documents.map(async ({ path, secrecy }) => { + logger.debug({ path, secrecy }); + return await loadDocument(path, secrecy); + }), + ); + await addDocuments(chunks.flat()); + }, + addDocuments, + makeDocumentView, + }); +} diff --git a/packages/nodejs/src/demo/stream.ts b/packages/nodejs/src/demo/stream.ts new file mode 100644 index 000000000..086258423 --- /dev/null +++ b/packages/nodejs/src/demo/stream.ts @@ -0,0 +1,137 @@ +import { E } from '@endo/eventual-send'; +import { makePipe } from '@endo/stream'; +import type { Reader, Writer } from '@endo/stream'; + +/** + * Type representing a remote vat that exposes stream methods + */ +type RemoteStreamVat = { + streamReadNext: (id: number) => Promise; + streamReadThrow: (id: number, error: Error) => Promise; + streamReadReturn: (id: number) => Promise; +}; + +/** + * Creates a local stream reader that forwards operations to a remote vat's stream + * + * @param vat - The remote vat that manages the actual stream + * @returns A hardened stream reader that implements the async iterator protocol + */ +export const makeVatStreamReader = + (vat: RemoteStreamVat) => (streamId: number) => { + const streamReader = { + async next() { + return E(vat).streamReadNext(streamId); + }, + async throw(error: Error) { + return E(vat).streamReadThrow(streamId, error); + }, + async return() { + return E(vat).streamReadReturn(streamId); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + return streamReader; + }; + +type Stream = { + id: number; + reader: Reader; + writer: Writer; +}; + +type StreamMaker = { + makeStream: () => Stream; + readStreamFacet: { + streamReadNext: (id: number) => Promise>; + streamReadThrow: ( + id: number, + error: Error, + ) => Promise>; + streamReadReturn: ( + id: number, + ) => Promise>; + }; + removeStream: (id: number) => void; +}; + +/** + * Creates a stream manager for the remote vat that maintains stream references + * and exposes methods to create and access streams + * + * @returns A hardened object with methods to create and access streams + */ +export const makeStreamMaker = (): StreamMaker => { + let counter = 0; + const streams = new Map(); + + /** + * Creates a new stream and returns its ID + * + * @returns The ID of the newly created stream + */ + const makeStream = (): Stream => { + const [reader, writer] = makePipe(); + const id = counter; + counter += 1; + const stream = harden({ id, reader, writer }); + streams.set(id, stream); + return stream; + }; + + /** + * Retrieves a stream by its ID and returns a Far reference to its reader + * + * @param id - The ID of the stream to retrieve + * @returns A Far reference to the stream reader + * @throws If no stream exists with the given ID + */ + const getStream = (id: number): Stream => { + const stream = streams.get(id); + if (!stream) { + throw new Error(`No stream with id ${id}`); + } + return stream; + }; + + const streamReadNext = async ( + id: number, + ): Promise> => { + const stream = getStream(id); + return await stream.reader.next(undefined); + }; + const streamReadThrow = async ( + id: number, + error: Error, + ): Promise> => { + const stream = getStream(id); + return await stream.reader.throw(error); + }; + const streamReadReturn = async ( + id: number, + ): Promise> => { + const stream = getStream(id); + return await stream.reader.return(undefined); + }; + + /** + * Removes a stream from the manager + * + * @param id - The ID of the stream to remove + */ + const removeStream = (id: number): void => { + streams.delete(id); + }; + + return harden({ + makeStream, + readStreamFacet: { + streamReadNext, + streamReadThrow, + streamReadReturn, + }, + removeStream, + }); +}; diff --git a/packages/nodejs/src/interfaces.ts b/packages/nodejs/src/interfaces.ts new file mode 100644 index 000000000..a318ee1e0 --- /dev/null +++ b/packages/nodejs/src/interfaces.ts @@ -0,0 +1,100 @@ +// @ts-check + +import { M } from '@endo/patterns'; + +export const WorkerInterface = M.interface('EndoWorker', {}); + +export const HostInterface = M.interface( + 'EndoHost', + {}, + { defaultGuards: 'passable' }, +); + +export const GuestInterface = M.interface( + 'EndoGuest', + {}, + { defaultGuards: 'passable' }, +); + +export const InvitationInterface = M.interface( + 'EndoInvitation', + {}, + { defaultGuards: 'passable' }, +); + +export const InspectorHubInterface = M.interface( + 'EndoInspectorHub', + {}, + { defaultGuards: 'passable' }, +); + +export const InspectorInterface = M.interface( + `EndoInspector`, + {}, + { defaultGuards: 'passable' }, +); + +export const BlobInterface = M.interface( + 'EndoBlobInterface', + {}, + { defaultGuards: 'passable' }, +); + +export const ResponderInterface = M.interface( + 'EndoResponder', + {}, + { + defaultGuards: 'passable', + }, +); + +export const EnvelopeInterface = M.interface('EndoEnvelope', {}); + +export const DismisserInterface = M.interface( + 'EndoDismisser', + {}, + { defaultGuards: 'passable' }, +); + +export const HandleInterface = M.interface( + 'EndoHandle', + {}, + { + defaultGuards: 'passable', + }, +); + +export const DirectoryInterface = M.interface( + 'EndoDirectory', + {}, + { + defaultGuards: 'passable', + }, +); + +export const DaemonFacetForWorkerInterface = M.interface( + 'EndoDaemonFacetForWorker', + {}, +); + +export const WorkerFacetForDaemonInterface = M.interface( + 'EndoWorkerFacetForDaemon', + {}, + { defaultGuards: 'passable' }, +); + +export const EndoInterface = M.interface( + 'Endo', + {}, + { + defaultGuards: 'passable', + }, +); + +export const AsyncIteratorInterface = M.interface( + 'AsyncIterator', + {}, + { + defaultGuards: 'passable', + }, +); diff --git a/packages/nodejs/src/kernel/VatWorkerService.ts b/packages/nodejs/src/kernel/VatWorkerService.ts index 6ddcecc7c..6ca79c1e2 100644 --- a/packages/nodejs/src/kernel/VatWorkerService.ts +++ b/packages/nodejs/src/kernel/VatWorkerService.ts @@ -10,6 +10,9 @@ import { NodeWorkerDuplexStream } from '@ocap/streams'; import type { DuplexStream } from '@ocap/streams'; import { makeLogger } from '@ocap/utils'; import type { Logger } from '@ocap/utils'; +import { mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { Worker as NodeWorker } from 'node:worker_threads'; // Worker file loads from the built dist directory, requires rebuild after change @@ -19,11 +22,21 @@ const DEFAULT_WORKER_FILE = new URL( import.meta.url, ).pathname; +export type MakeDocumentRoot = (vatId: VatId) => Promise; + +const makeDocumentRootDefault = async (vatId: VatId): Promise => { + const root = join(tmpdir(), 'vats', vatId); + await mkdir(root, { recursive: true }); + return root; +}; + export class NodejsVatWorkerService implements VatWorkerService { readonly #logger: Logger; readonly #workerFilePath: string; + readonly #makeDocumentRoot: MakeDocumentRoot; + workers = new Map< VatId, { worker: NodeWorker; stream: DuplexStream } @@ -36,12 +49,15 @@ export class NodejsVatWorkerService implements VatWorkerService { * @param args - A bag of optional arguments. * @param args.workerFilePath - An optional path to a file defining the worker's routine. Defaults to 'vat-worker.mjs'. * @param args.logger - An optional {@link Logger}. Defaults to a new logger labeled '[vat worker client]'. + * @param args.makeDocumentRoot - An optional function that returns a path to a directory for storing documents. Defaults to a function that creates a directory in the system temp directory. */ constructor(args: { workerFilePath?: string | undefined; + makeDocumentRoot?: MakeDocumentRoot | undefined; logger?: Logger | undefined; }) { this.#workerFilePath = args.workerFilePath ?? DEFAULT_WORKER_FILE; + this.#makeDocumentRoot = args.makeDocumentRoot ?? makeDocumentRootDefault; this.#logger = args.logger ?? makeLogger('[vat worker service]'); } @@ -54,6 +70,7 @@ export class NodejsVatWorkerService implements VatWorkerService { const worker = new NodeWorker(this.#workerFilePath, { env: { NODE_VAT_ID: vatId, + NODE_DOCUMENT_ROOT: await this.#makeDocumentRoot(vatId), }, }); worker.once('online', () => { diff --git a/packages/nodejs/src/kernel/make-kernel.test.ts b/packages/nodejs/src/kernel/make-kernel.test.ts index e45982464..47991ea5a 100644 --- a/packages/nodejs/src/kernel/make-kernel.test.ts +++ b/packages/nodejs/src/kernel/make-kernel.test.ts @@ -24,7 +24,7 @@ describe('makeKernel', () => { }); it('should return a Kernel', async () => { - const kernel = await makeKernel(kernelPort); + const kernel = await makeKernel({ port: kernelPort }); expect(kernel).toBeInstanceOf(Kernel); }); diff --git a/packages/nodejs/src/kernel/make-kernel.ts b/packages/nodejs/src/kernel/make-kernel.ts index 2e6a9ad44..1e80ff8ea 100644 --- a/packages/nodejs/src/kernel/make-kernel.ts +++ b/packages/nodejs/src/kernel/make-kernel.ts @@ -1,37 +1,44 @@ import type { KernelCommand, KernelCommandReply } from '@ocap/kernel'; -import { Kernel } from '@ocap/kernel'; +import { isKernelCommand, Kernel } from '@ocap/kernel'; import { makeSQLKVStore } from '@ocap/store/sqlite/nodejs'; import { NodeWorkerDuplexStream } from '@ocap/streams'; import { MessagePort as NodeMessagePort } from 'node:worker_threads'; import { NodejsVatWorkerService } from './VatWorkerService.ts'; +type MakeKernelArgs = { + port: NodeMessagePort; + vatWorkerServiceOptions?: ConstructorParameters< + typeof NodejsVatWorkerService + >[0]; +}; + /** * The main function for the kernel worker. * - * @param port - The kernel's end of a node:worker_threads MessageChannel - * @param workerFilePath - The path to a file defining each vat worker's routine. - * @param resetStorage - If true, clear kernel storage as part of setting up the kernel. + * @param params - The parameters for the kernel. + * @param params.port - The kernel's end of a node:worker_threads MessageChannel + * @param params.vatWorkerServiceOptions - The options for the vat worker service. * @returns The kernel, initialized. */ -export async function makeKernel( - port: NodeMessagePort, - workerFilePath?: string, - resetStorage: boolean = false, -): Promise { +export async function makeKernel({ + port, + vatWorkerServiceOptions, +}: MakeKernelArgs): Promise { const nodeStream = new NodeWorkerDuplexStream< KernelCommand, KernelCommandReply - >(port); - const vatWorkerClient = new NodejsVatWorkerService({ workerFilePath }); + >(port, isKernelCommand); + const vatWorkerClient = new NodejsVatWorkerService( + vatWorkerServiceOptions ?? {}, + ); // Initialize kernel store. const kvStore = await makeSQLKVStore(); + kvStore.clear(); // Create and start kernel. - const kernel = await Kernel.make(nodeStream, vatWorkerClient, kvStore, { - resetStorage, - }); + const kernel = await Kernel.make(nodeStream, vatWorkerClient, kvStore); return kernel; } diff --git a/packages/nodejs/src/reader-ref.ts b/packages/nodejs/src/reader-ref.ts new file mode 100644 index 000000000..1c6a6e781 --- /dev/null +++ b/packages/nodejs/src/reader-ref.ts @@ -0,0 +1,100 @@ +// @ts-check + +import { encodeBase64 } from '@endo/base64'; +import { makeExo } from '@endo/exo'; +import type { FarRef } from '@endo/far'; +import { mapReader } from '@endo/stream'; +import type { Reader, Stream } from '@endo/stream'; + +import { AsyncIteratorInterface } from './interfaces.ts'; +import type { SomehowAsyncIterable } from './types.ts'; + +/** + * Returns the iterator for the given iterable object. + * Supports both synchronous and asynchronous iterables. + * + * @param iterable - An iterable object. + * @template Item The item type of the iterable. + * @returns An async iterator. + */ +export const asyncIterate = ( + iterable: SomehowAsyncIterable, +): AsyncIterableIterator => { + let iterator: AsyncIterator; + + if (Symbol.asyncIterator in iterable) { + iterator = (iterable as AsyncIterable)[Symbol.asyncIterator](); + } else if (Symbol.iterator in iterable) { + iterator = { + next: async () => (iterable as Iterable)[Symbol.iterator]().next(), + [Symbol.asyncIterator]() { + return this; + }, + } as AsyncIterator; + } else if ('next' in iterable) { + const syncIterator = iterable as { next: () => IteratorResult }; + iterator = { + next: async () => syncIterator.next(), + [Symbol.asyncIterator]() { + return this; + }, + } as AsyncIterator; + } else { + throw new Error('Invalid iterable provided'); + } + + return iterator as AsyncIterableIterator; +}; + +/** + * Creates a reference to an async iterator that can be used across vat boundaries. + * + * @param iterable - An iterable object. + * @template Item The item type of the iterable. + * @returns A reference to an async iterator. + */ +export const makeIteratorRef = ( + iterable: SomehowAsyncIterable, +): FarRef> => { + const iterator = asyncIterate(iterable); + + return makeExo('AsyncIterator', AsyncIteratorInterface, { + async next(): Promise> { + return iterator.next(); + }, + + async return(value?: unknown): Promise> { + if (iterator.return !== undefined) { + return iterator.return(value); + } + return harden({ done: true, value: undefined }); + }, + + async throw(error: Error): Promise> { + if (iterator.throw !== undefined) { + return iterator.throw(error); + } + return harden({ done: true, value: undefined }); + }, + + [Symbol.asyncIterator]() { + return this; + }, + }); +}; + +/** + * Creates a reference to a reader that converts a Uint8Array stream to base64 strings. + * + * @param readable - A stream of Uint8Arrays. + * @returns A reader that emits base64 strings. + */ +export const makeReaderRef = ( + readable: SomehowAsyncIterable, +): FarRef> => + makeIteratorRef( + mapReader( + asyncIterate(readable) as Stream, + encodeBase64, + ), + ); diff --git a/packages/nodejs/src/ref-reader.ts b/packages/nodejs/src/ref-reader.ts new file mode 100644 index 000000000..5a95f7424 --- /dev/null +++ b/packages/nodejs/src/ref-reader.ts @@ -0,0 +1,52 @@ +import { decodeBase64 } from '@endo/base64'; +import { makeExo } from '@endo/exo'; +import { E } from '@endo/far'; +import type { ERef } from '@endo/far'; +import { mapReader } from '@endo/stream'; +import type { Stream } from '@endo/stream'; + +import { AsyncIteratorInterface } from './interfaces.ts'; + +/** + * Creates a reference to an async iterator that can be used across vat boundaries. + * + * @param iteratorRef - A reference to an async iterator. + * @template TValue The type of the values in the iterator. + * @template TReturn The type of the return value of the iterator. + * @template TNext The type of the next value of the iterator. + * @returns A reference to an async iterator. + */ +export const makeRefIterator = ( + iteratorRef: ERef>, +): Stream => { + const iterator = makeExo('AsyncIterator', AsyncIteratorInterface, { + async next(value: undefined): Promise> { + return E(iteratorRef).next(value); + }, + + async return(value: undefined): Promise> { + return E(iteratorRef).return(value); + }, + + async throw(error: Error): Promise> { + return E(iteratorRef).throw(error); + }, + + [Symbol.asyncIterator]() { + return this; + }, + }); + + return iterator; +}; + +/** + * Creates a reference to a reader that can be used across vat boundaries. + * + * @param readerRef - A reference to a reader. + * @returns A reference to a reader. + */ +export const makeRefReader = ( + readerRef: ERef>, +): AsyncIterableIterator => + mapReader(makeRefIterator(readerRef), decodeBase64); diff --git a/packages/nodejs/src/types.ts b/packages/nodejs/src/types.ts new file mode 100644 index 000000000..e00c69874 --- /dev/null +++ b/packages/nodejs/src/types.ts @@ -0,0 +1,1108 @@ +import type { ERef } from '@endo/eventual-send'; +import type { FarRef } from '@endo/far'; +import type { Passable } from '@endo/pass-style'; +import type { Reader, Writer, Stream } from '@endo/stream'; + +export type SomehowAsyncIterable = + | AsyncIterable + | Iterable + | { next: () => IteratorResult }; + +export type Config = { + statePath: string; + ephemeralStatePath: string; + cachePath: string; + sockPath: string; +}; + +export type Sha512 = { + update: (chunk: Uint8Array) => void; + updateText: (chunk: string) => void; + digestHex: () => string; +}; + +export type Connection = { + reader: Reader; + writer: Writer; + closed: Promise; +}; + +export type HttpRequest = { + method: string; + url: string; + headers: Record; +}; + +export type HttpResponse = { + status: number; + headers: Record; + content: AsyncIterable | string | Uint8Array | undefined; +}; + +export type HttpRespond = (request: HttpRequest) => Promise; +export type HttpConnect = ( + connection: Connection, + request: HttpRequest, +) => void; + +export type MignonicPowers = { + connection: { + reader: Reader; + writer: Writer; + }; +}; + +type IdRecord = { + number: string; + node: string; +}; + +type EndoFormula = { + type: 'endo'; + networks: string; + peers: string; + host: string; + leastAuthority: string; +}; + +type LoopbackNetworkFormula = { + type: 'loopback-network'; +}; + +type WorkerFormula = { + type: 'worker'; +}; + +export type WorkerDeferredTaskParams = { + workerId: string; +}; + +/** + * Deferred tasks parameters for `host` and `guest` formulas. + */ +export type AgentDeferredTaskParams = { + agentId: string; + handleId: string; +}; + +type HostFormula = { + type: 'host'; + handle: string; + worker: string; + inspector: string; + petStore: string; + endo: string; + networks: string; +}; + +type GuestFormula = { + type: 'guest'; + handle: string; + hostHandle: string; + hostAgent: string; + petStore: string; + worker: string; +}; + +type LeastAuthorityFormula = { + type: 'least-authority'; +}; + +type MarshalFormula = { + type: 'marshal'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: any; + slots: string[]; +}; + +type EvalFormula = { + type: 'eval'; + worker: string; + source: string; + names: string[]; // lexical names + values: string[]; // formula identifiers + // TODO formula slots +}; + +export type MarshalDeferredTaskParams = { + marshalFormulaNumber: string; + marshalId: string; +}; + +export type EvalDeferredTaskParams = { + endowmentIds: string[]; + evalId: string; + workerId: string; +}; + +type ReadableBlobFormula = { + type: 'readable-blob'; + content: string; +}; + +export type ReadableBlobDeferredTaskParams = { + readableBlobId: string; +}; + +type LookupFormula = { + type: 'lookup'; + + /** + * The formula identifier of the naming hub to call lookup on. + * A "naming hub" is an object with a variadic `lookup()` method. + */ + hub: string; + + /** + * The pet name path. + */ + path: string[]; +}; + +type MakeUnconfinedFormula = { + type: 'make-unconfined'; + worker: string; + powers: string; + specifier: string; + // TODO formula slots +}; + +type MakeBundleFormula = { + type: 'make-bundle'; + worker: string; + powers: string; + bundle: string; + // TODO formula slots +}; + +export type MakeCapletDeferredTaskParams = { + capletId: string; + powersId: string; + workerId: string; +}; + +type PeerFormula = { + type: 'peer'; + networks: string; + node: string; + addresses: string[]; +}; + +type HandleFormula = { + type: 'handle'; + agent: string; +}; + +type KnownPeersStoreFormula = { + type: 'known-peers-store'; +}; + +type PetStoreFormula = { + type: 'pet-store'; +}; + +type PetInspectorFormula = { + type: 'pet-inspector'; + petStore: string; +}; + +type DirectoryFormula = { + type: 'directory'; + petStore: string; +}; + +type InvitationFormula = { + type: 'invitation'; + hostAgent: string; // identifier + hostHandle: string; // identifier + guestName: string; +}; + +export type InvitationDeferredTaskParams = { + invitationId: string; +}; + +export type Formula = + | EndoFormula + | LoopbackNetworkFormula + | WorkerFormula + | HostFormula + | GuestFormula + | LeastAuthorityFormula + | MarshalFormula + | EvalFormula + | ReadableBlobFormula + | LookupFormula + | MakeUnconfinedFormula + | MakeBundleFormula + | HandleFormula + | PetInspectorFormula + | KnownPeersStoreFormula + | PetStoreFormula + | DirectoryFormula + | PeerFormula + | InvitationFormula; + +export type Builtins = { + // eslint-disable-next-line @typescript-eslint/naming-convention + NONE: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + MAIN: string; +}; + +export type Specials = { + [specialName: string]: (builtins: Builtins) => Formula; +}; + +export type Responder = { + respondId(id: string | Promise): void; +}; + +export type Request = { + type: 'request'; + description: string; + responder: ERef; + settled: Promise<'fulfilled' | 'rejected'>; +}; + +export type Package = { + type: 'package'; + strings: string[]; // text that appears before, between, and after named formulas. + names: string[]; // edge names + ids: string[]; // formula identifiers +}; + +export type Message = Request | Package; + +export type EnvelopedMessage = Message & { + to: string; + from: string; +}; + +export type Dismisser = { + dismiss(): void; +}; + +export type StampedMessage = EnvelopedMessage & { + number: number; + date: string; + dismissed: Promise; + dismisser: ERef; +}; + +export type Invitation = { + accept(guestHandleId: string): Promise; +}; + +export type Topic< + TRead, + TWrite = undefined, + TReadReturn = undefined, + TWriteReturn = undefined, +> = { + publisher: Stream; + subscribe(): Stream; +}; + +/** + * The cancellation context of a live value associated with a formula. + */ +export type Context = { + /** + * The identifier for the associated formula. + */ + id: string; + /** + * Cancel the value, preparing it for garbage collection. Cancellation + * propagates to all values that depend on this value. + * + * @param reason - The reason for the cancellation. + * @param logPrefix - The prefix to use within the log. + * @returns A promise that is resolved when the value is cancelled and + * can be garbage collected. + */ + cancel: (reason?: Error, logPrefix?: string) => Promise; + + /** + * A promise that is rejected when the context is cancelled. + * Once rejected, the cancelled value may initiate any teardown procedures. + */ + cancelled: Promise; + + /** + * A promise that is resolved when the context is disposed. This occurs + * after the `cancelled` promise is rejected, and after all disposal hooks + * have been run. + * Once resolved, the value may be garbage collected at any time. + */ + disposed: Promise; + + /** + * @param id - The formula identifier of the value whose + * cancellation should cause this value to be cancelled. + */ + thisDiesIfThatDies: (id: string) => void; + + /** + * @param id - The formula identifier of the value that should + * be cancelled if this value is cancelled. + */ + thatDiesIfThisDies: (id: string) => void; + + /** + * @param hook - A hook to run when the value is cancelled. + */ + onCancel: (hook: () => void | Promise) => void; +}; + +export type FarContext = { + id: () => string; + cancel: (reason: Error) => Promise; + whenCancelled: () => Promise; + whenDisposed: () => Promise; + addDisposalHook: Context['onCancel']; +}; + +export type Controller = { + value: Promise; + context: Context; +}; + +export type FormulaMaker = ( + formula: ThisFormula, + context: Context, + id: string, + number: string, +) => unknown; + +export type FormulaMakerTable = { + [T in Formula['type']]: FormulaMaker<{ type: T } & Formula>; +}; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type Envelope = {}; + +export type Handle = { + receive(envelope: Envelope, allegedFromId: string): void; + open(envelope: Envelope): EnvelopedMessage; +}; + +export type MakeSha512 = () => Sha512; + +export type PetStoreNameChange = + | { add: string; value: IdRecord } + | { remove: string }; + +export type PetStoreIdNameChange = + | { add: IdRecord; names: string[] } + | { remove: IdRecord; names?: string[] }; + +export type NameChangesTopic = Topic; + +export type IdChangesTopic = Topic; + +export type PetStore = { + has(petName: string): boolean; + identifyLocal(petName: string): string | undefined; + list(): string[]; + /** + * Subscribe to all name changes. First publishes all existing names in alphabetical order. + * Then publishes diffs as names are added and removed. + */ + followNameChanges(): AsyncGenerator; + /** + * Subscribe to name changes for the specified id. First publishes the existing names for the id. + * Then publishes diffs as names are added and removed, or if the id is itself removed. + * + * @throws If attempting to follow an id with no names. + */ + followIdNameChanges( + id: string, + ): AsyncGenerator; + write(petName: string, id: string): Promise; + remove(petName: string): Promise; + rename(fromPetName: string, toPetName: string): Promise; + /** + * @param id The formula identifier to look up. + * @returns The formula identifier for the given pet name, or `undefined` if the pet name is not found. + */ + reverseIdentify(id: string): string[]; +}; + +/** + * `add` and `remove` are locators. + */ +export type LocatorNameChange = + | { add: string; names: string[] } + | { remove: string; names?: string[] }; + +export type NameHub = { + has(...petNamePath: string[]): Promise; + identify(...petNamePath: string[]): Promise; + locate(...petNamePath: string[]): Promise; + reverseLocate(locator: string): Promise; + followLocatorNameChanges( + locator: string, + ): AsyncGenerator; + list(...petNamePath: string[]): Promise; + listIdentifiers(...petNamePath: string[]): Promise; + followNameChanges( + ...petNamePath: string[] + ): AsyncGenerator; + lookup(petNamePath: string | string[]): Promise; + reverseLookup(value: unknown): string[]; + write(petNamePath: string | string[], id: string): Promise; + remove(...petNamePath: string[]): Promise; + move(fromPetName: string[], toPetName: string[]): Promise; + copy(fromPetName: string[], toPetName: string[]): Promise; +}; + +export type EndoDirectory = { + makeDirectory(petNamePath: string[]): Promise; +} & NameHub; + +export type MakeDirectoryNode = (petStore: PetStore) => EndoDirectory; + +export type Mail = { + handle: () => Handle; + // Partial inheritance from PetStore: + petStore: PetStore; + // Mail operations: + listMessages(): Promise; + followMessages(): AsyncGenerator; + resolve(messageNumber: number, resolutionName: string): Promise; + reject(messageNumber: number, message?: string): Promise; + adopt( + messageNumber: number, + edgeName: string, + petName: string[], + ): Promise; + dismiss(messageNumber: number): Promise; + request( + recipientName: string, + what: string, + responseName: string, + ): Promise; + send( + recipientName: string, + strings: string[], + edgeNames: string[], + petNames: string[], + ): Promise; + deliver(message: EnvelopedMessage): void; +}; + +export type MakeMailbox = (args: { + selfId: string; + petStore: PetStore; + directory: EndoDirectory; + context: Context; +}) => Mail; + +export type RequestFn = ( + what: string, + responseName: string, + guestId: string, + guestPetStore: PetStore, +) => Promise; + +export type EndoReadable = { + sha512(): string; + streamBase64(): FarRef>; + text(): Promise; + json(): Promise; +}; +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type EndoWorker = {}; + +export type MakeHostOrGuestOptions = { + agentName?: string; + introducedNames?: Record; +}; + +export type EndoPeer = { + provide: (id: string) => Promise; +}; + +export type EndoGateway = { + provide: (id: string) => Promise; +}; + +export type EndoGreeter = { + hello: ( + remoteNodeKey: string, + remoteGateway: Promise, + cancel: (error: Error) => void, + cancelled: Promise, + ) => Promise; +}; + +export type PeerInfo = { + node: string; + addresses: string[]; +}; + +export type EndoNetwork = { + supports: (network: string) => boolean; + addresses: () => string[]; + connect: (address: string, farContext: FarContext) => Promise; +}; + +export type EndoAgent = { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + handle: () => {}; + listMessages: Mail['listMessages']; + followMessages: Mail['followMessages']; + resolve: Mail['resolve']; + reject: Mail['reject']; + adopt: Mail['adopt']; + dismiss: Mail['dismiss']; + request: Mail['request']; + send: Mail['send']; + deliver: Mail['deliver']; + /** + * @param id The formula identifier to look up. + * @returns The formula identifier for the given pet name, or `undefined` if the pet name is not found. + */ + reverseIdentify(id: string): string[]; +} & EndoDirectory; + +export type EndoGuest = {} & EndoAgent; + +export type FarEndoGuest = FarRef; + +export type EndoHost = { + storeBlob( + readerRef: ERef>, + petName: string, + ): Promise>; + storeValue( + value: Value, + petName: string | string[], + ): Promise; + provideGuest( + petName?: string, + opts?: MakeHostOrGuestOptions, + ): Promise; + provideHost( + petName?: string, + opts?: MakeHostOrGuestOptions, + ): Promise; + makeDirectory(petNamePath: string[]): Promise; + provideWorker(petNamePath: string[]): Promise; + evaluate( + workerPetName: string | undefined, + source: string, + codeNames: string[], + petNames: string[], + resultName?: string[], + ): Promise; + makeUnconfined( + workerName: string | undefined | 'MAIN', + specifier: string, + powersName: string | 'NONE' | 'SELF' | 'ENDO', + resultName?: string, + ): Promise; + makeBundle( + workerPetName: string | undefined, + bundleName: string, + powersName: string, + resultName?: string, + ): Promise; + cancel(petName: string, reason: Error): Promise; + greeter(): Promise; + gateway(): Promise; + getPeerInfo(): Promise; + addPeerInfo(peerInfo: PeerInfo): Promise; + invite(guestName: string): Promise; + accept( + invitationId: string, + guestHandleId: string, + guestName: string, + ): Promise; +} & EndoAgent; + +export type EndoHostController = {} & Controller>; + +export type EndoInspector = { + lookup: (petName: Record) => Promise; + list: () => Record[]; +}; + +export type KnownEndoInspectors = { + eval: EndoInspector<'endowments' | 'source' | 'worker'>; + 'make-unconfined': EndoInspector<'host'>; + 'make-bundle': EndoInspector<'bundle' | 'powers' | 'worker'>; + guest: EndoInspector<'bundle' | 'powers'>; +} & Record; + +export type EndoBootstrap = { + ping: () => Promise; + terminate: () => Promise; + host: () => Promise; + leastAuthority: () => Promise; + greeter: () => Promise; + gateway: () => Promise; + reviveNetworks: () => Promise; + addPeerInfo: (peerInfo: PeerInfo) => Promise; +}; + +export type CryptoPowers = { + makeSha512: () => Sha512; + randomHex512: () => Promise; +}; + +export type FilePowers = { + makeFileReader: (path: string) => Reader; + makeFileWriter: (path: string) => Writer; + writeFileText: (path: string, text: string) => Promise; + readFileText: (path: string) => Promise; + maybeReadFileText: (path: string) => Promise; + readDirectory: (path: string) => Promise; + makePath: (path: string) => Promise; + joinPath: (...components: string[]) => string; + removePath: (path: string) => Promise; + renamePath: (source: string, target: string) => Promise; +}; + +export type AssertValidNameFn = (name: string) => void; + +export type PetStorePowers = { + makeIdentifiedPetStore: ( + id: string, + formulaType: 'pet-store' | 'known-peers-store', + assertValidName: AssertValidNameFn, + ) => Promise; +}; + +export type SocketPowers = { + servePort: (args: { + port: number; + host?: string; + cancelled: Promise; + }) => Promise<{ + port: number; + connections: Reader; + }>; + connectPort: (args: { + port: number; + host?: string; + cancelled: Promise; + }) => Promise; + servePath: (args: { + path: string; + cancelled: Promise; + }) => Promise>; +}; + +export type NetworkPowers = SocketPowers & { + makePrivatePathService: ( + endoBootstrap: FarRef, + sockPath: string, + cancelled: Promise, + exitWithError: (error: Error) => void, + ) => { started: Promise; stopped: Promise }; +}; + +export type DaemonicPersistencePowers = { + initializePersistence: () => Promise; + provideRootNonce: () => Promise<{ + rootNonce: string; + isNewlyCreated: boolean; + }>; + makeContentSha512Store: () => { + store: (readable: AsyncIterable) => Promise; + fetch: (sha512: string) => EndoReadable; + }; + readFormula: (formulaNumber: string) => Promise; + writeFormula: (formulaNumber: string, formula: Formula) => Promise; +}; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type DaemonWorkerFacet = {}; + +export type WorkerDaemonFacet = { + terminate(): Promise; + evaluate( + source: string, + names: string[], + values: unknown[], + id: string, + cancelled: Promise, + ): Promise; + makeBundle( + bundle: ERef, + powers: ERef, + context: ERef, + ): Promise; + makeUnconfined( + path: string, + powers: ERef, + context: ERef, + ): Promise; +}; + +export type DaemonicControlPowers = { + makeWorker: ( + id: string, + daemonWorkerFacet: DaemonWorkerFacet, + cancelled: Promise, + ) => Promise<{ + workerTerminated: Promise; + workerDaemonFacet: ERef; + }>; +}; + +export type DaemonicPowers = { + crypto: CryptoPowers; + petStore: PetStorePowers; + persistence: DaemonicPersistencePowers; + control: DaemonicControlPowers; +}; + +type FormulateResult = Promise<{ + id: string; + value: Item; +}>; + +export type DeferredTask> = ( + ids: Readonly, +) => Promise; + +/** + * A collection of deferred tasks (i.e. async functions) that can be executed in + * parallel. + */ +export type DeferredTasks> = { + execute(identifiers: Readonly): Promise; + push(value: DeferredTask): void; +}; + +type FormulateNumberedGuestParams = { + guestFormulaNumber: string; + handleId: string; + guestId: string; + hostAgentId: string; + hostHandleId: string; + storeId: string; + workerId: string; +}; + +type FormulateHostDependenciesParams = { + endoId: string; + networksDirectoryId: string; + specifiedWorkerId?: string; +}; + +type FormulateNumberedHostParams = { + hostFormulaNumber: string; + hostId: string; + handleId: string; + workerId: string; + storeId: string; + inspectorId: string; + endoId: string; + networksDirectoryId: string; +}; + +export type FormulaValueTypes = { + directory: EndoDirectory; + network: EndoNetwork; + peer: EndoGateway; + 'pet-store': PetStore; + 'readable-blob': EndoReadable; + endo: EndoBootstrap; + guest: EndoGuest; + handle: Handle; + host: EndoHost; + invitation: Invitation; + worker: EndoWorker; +}; + +export type ProvideTypes = FormulaValueTypes & { + agent: EndoAgent; + hub: NameHub; +}; + +export type Provide = < + Type extends keyof ProvideTypes, + Provided extends ProvideTypes[Type], +>( + id: string, + expectedType?: Type, +) => Promise; + +export type DaemonCore = { + cancelValue: (id: string, reason: Error) => Promise; + + formulate: ( + formulaNumber: string, + formula: Formula, + ) => Promise<{ + id: string; + value: unknown; + }>; + + formulateBundle: ( + hostAgentId: string, + hostHandleId: string, + bundleId: string, + deferredTasks: DeferredTasks, + specifiedWorkerId?: string, + specifiedPowersId?: string, + ) => FormulateResult; + + formulateDirectory: () => FormulateResult; + + formulateEndo: ( + specifiedFormulaNumber: string, + ) => FormulateResult>; + + formulateMarshalValue: ( + value: Passable, + deferredTasks: DeferredTasks, + ) => FormulateResult; + + formulateEval: ( + nameHubId: string, + source: string, + codeNames: string[], + endowmentIdsOrPaths: (string | string[])[], + deferredTasks: DeferredTasks, + specifiedWorkerId?: string, + ) => FormulateResult; + + formulateGuest: ( + hostId: string, + hostHandleId: string, + deferredTasks: DeferredTasks, + ) => FormulateResult; + + /** + * Helper for callers of {@link formulateNumberedGuest}. + * + * @param hostId - The formula identifier of the host to formulate a guest for. + * @returns The formula identifiers for the guest formulation's dependencies. + */ + formulateGuestDependencies: ( + hostAgentId: string, + hostHandleId: string, + ) => Promise>; + + formulateHost: ( + endoId: string, + networksDirectoryId: string, + deferredTasks: DeferredTasks, + specifiedWorkerId?: string | undefined, + ) => FormulateResult; + + /** + * Helper for callers of {@link formulateNumberedHost}. + * + * @param specifiedIdentifiers - The existing formula identifiers specified to the host formulation. + * @returns The formula identifiers for all of the host formulation's dependencies. + */ + formulateHostDependencies: ( + specifiedIdentifiers: FormulateHostDependenciesParams, + ) => Promise>; + + formulateLoopbackNetwork: () => FormulateResult; + + formulateNetworksDirectory: () => FormulateResult; + + formulateNumberedGuest: ( + identifiers: FormulateNumberedGuestParams, + ) => FormulateResult; + + formulateNumberedHost: ( + identifiers: FormulateNumberedHostParams, + ) => FormulateResult; + + formulatePeer: ( + networksId: string, + nodeId: string, + addresses: string[], + ) => FormulateResult; + + formulateReadableBlob: ( + readerRef: ERef>, + deferredTasks: DeferredTasks, + ) => FormulateResult>; + + formulateInvitation: ( + hostAgentId: string, + hostHandleId: string, + guestName: string, + deferredTasks: DeferredTasks, + ) => FormulateResult; + + formulateUnconfined: ( + hostAgentId: string, + hostHandleId: string, + specifier: string, + deferredTasks: DeferredTasks, + specifiedWorkerId?: string, + specifiedPowersId?: string, + ) => FormulateResult; + + formulateWorker: ( + deferredTasks: DeferredTasks, + ) => FormulateResult; + + getAllNetworkAddresses: (networksDirectoryId: string) => Promise; + + getIdForRef: (ref: unknown) => string | undefined; + + getTypeForId: (id: string) => Promise; + + makeDirectoryNode: MakeDirectoryNode; + + makeMailbox: MakeMailbox; + + provide: Provide; + + provideController: (id: string) => Controller; + + provideAgentForHandle: (id: string) => Promise>; +}; + +export type DaemonCoreExternal = { + formulateEndo: DaemonCore['formulateEndo']; + nodeId: string; + provide: DaemonCore['provide']; +}; + +export type SerialJobs = { + enqueue: (asyncFn?: () => Promise) => Promise; +}; + +export type Multimap = { + /** + * @param key - The key to add a value for. + * @param value - The value to add. + */ + add(key: Key, value: Value): void; + + /** + * @param key - The key whose value to delete. + * @param value - The value to delete. + * @returns `true` if the key was found and the value was deleted, `false` otherwise. + */ + delete(key: Key, value: Value): boolean; + + /** + * @param key - The key whose values to delete + * @returns `true` if the key was found and its values were deleted, `false` otherwise. + */ + deleteAll(key: Key): boolean; + + /** + * @param key - The key whose first value to retrieve + * @returns The first value associated with the key. + */ + get(key: Key): Value | undefined; + + /** + * @param key - The key whose values to retrieve. + * @returns An array of all values associated with the key. + */ + getAllFor(key: Key): Value[]; + + /** + * @param key - The key whose presence to check for. + * @returns `true` if the key is present and `false` otherwise. + */ + has(key: Key): boolean; +}; + +/** + * A multimap backed by a WeakMap. + */ +export type WeakMultimap = Multimap; + +export type BidirectionalMultimap = { + /** + * @param key - The key to add a value for. + * @param value - The value to add. + * @throws If the value has already been added for a different key. + */ + add(key: Key, value: Value): void; + + /** + * @param key - The key whose value to delete. + * @param value - The value to delete. + * @returns `true` if the key was found and the value was deleted, `false` otherwise. + */ + delete(key: Key, value: Value): boolean; + + /** + * @param key - The key whose values to delete. + * @returns `true` if the key was found and its values were deleted, `false` otherwise. + */ + deleteAll(key: Key): boolean; + + /** + * @param key - The key whose presence to check for. + * @returns `true` if the key is present and `false` otherwise. + */ + has(key: Key): boolean; + + /** + * @param value - The value whose presence to check for. + * @returns `true` if the value is present and `false` otherwise. + */ + hasValue(value: Value): boolean; + + /** + * @param key - The key whose first value to retrieve. + * @returns The first value associated with the key. + */ + get(key: Key): Value | undefined; + + /** + * @param value - The value whose key to retrieve. + * @returns The key associated with the value. + */ + getKey(value: Value): Key | undefined; + + /** + * @returns An array of all values, for all keys. + */ + getAll(): Value[]; + + /** + * @param key - The key whose values to retrieve. + * @returns An array of all values associated with the key. + */ + getAllFor(key: Key): Value[]; +}; + +export type RemoteControl = { + accept( + remoteGateway: Promise, + cancel: (error: Error) => void | Promise, + cancelled: Promise, + dispose?: () => void, + ): void; + connect( + getRemoteGateway: () => Promise, + cancel: (error: Error) => void | Promise, + cancelled: Promise, + dispose?: () => void, + ): Promise; +}; + +export type RemoteControlState = { + accept( + remoteGateway: Promise, + cancel: (error: Error) => void | Promise, + cancelled: Promise, + dispose: () => void, + ): RemoteControlState; + connect( + getRemoteGateway: () => Promise, + cancel: (error: Error) => void | Promise, + cancelled: Promise, + dispose: () => void, + ): { state: RemoteControlState; remoteGateway: Promise }; +}; diff --git a/packages/nodejs/src/vat/powers/load-document.ts b/packages/nodejs/src/vat/powers/load-document.ts new file mode 100644 index 000000000..fc44b4cf5 --- /dev/null +++ b/packages/nodejs/src/vat/powers/load-document.ts @@ -0,0 +1,75 @@ +import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; +import { readFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +export type Args = + | { + useTmpDir?: boolean; + root: string; + } + | { + useTmpDir: true; + root?: never; + }; + +type LoadDocument = ( + name: string, + secrecy: string, +) => Promise< + { + pageContent: string; + metadata: { source: string; secrecy: string }; + }[] +>; + +/** + * Makes a view which loads .txt documents from the root + * + * @param args - The arguments + * @param args.root - The base filepath to load documents from. + * @param args.useTmpDir - Whether to . + * @returns A method for loading a document by name. + */ +export default function makeLoadDocument({ + root, + useTmpDir, +}: Args): LoadDocument { + if (root === undefined && useTmpDir === undefined) { + throw new Error('Bad arguments', { cause: { root, useTmpDir } }); + } + let base = useTmpDir ? tmpdir() : undefined; + if (root) { + base = base ? join(base, root) : root; + } + // XXX name might exit 'base' via '..'s or symlinks + const resolve = (name: string): string => `${base}/${name}.txt`; + + const loadDocument: LoadDocument = async (name: string, secrecy: string) => { + const source = resolve(name); + const pageContent = await readFile(source, 'utf8'); + if (!pageContent) { + throw new Error(`Could not find document ${name}`); + } + const splitter = new RecursiveCharacterTextSplitter({ + chunkSize: 384, + chunkOverlap: 64, + }); + const splitDocs = await splitter.splitDocuments([ + { + pageContent, + metadata: {}, + }, + ]); + if (!splitDocs.length) { + throw new Error(`Document ${name} produced no content after splitting`); + } + return splitDocs.map((doc) => ({ + pageContent: doc.pageContent, + // XXX secrecy should be handled in a separate routine + metadata: { source: name, secrecy, ...doc.metadata }, + })); + }; + + return loadDocument; +} diff --git a/packages/nodejs/src/vat/powers/make-powers.ts b/packages/nodejs/src/vat/powers/make-powers.ts new file mode 100644 index 000000000..fbcb7e93d --- /dev/null +++ b/packages/nodejs/src/vat/powers/make-powers.ts @@ -0,0 +1,57 @@ +import makeLoadDocument from './load-document.ts'; +import type { Args as LoadDocumentArgs } from './load-document.ts'; +import makeGetOllama from './ollama.ts'; +import makeGetVectorStore from './vector-store.ts'; + +type Args = { + loadDocument?: LoadDocumentArgs; + ollama?: { + host: string; + }; + vectorStore?: { + host: string; + model: string; + }; +}; + +/** + * Make the powers for a vat. + * + * @param param0 - The args. + * @param param0.loadDocument - The loadDocument power. + * @param param0.ollama - The ollama power. + * @param param0.vectorStore - The vectorStore power. + * @returns A Powers object. + */ +export default async function makePowers({ + loadDocument, + ollama, + vectorStore, +}: Args): Promise> { + let powers = { + setInterval, + clearInterval, + getStdout: () => process.stdout, + } as Record; + + if (loadDocument) { + powers = { + ...powers, + loadDocument: makeLoadDocument(loadDocument), + }; + } + if (ollama) { + powers = { + ...powers, + ollama: await makeGetOllama(ollama), + }; + } + if (vectorStore) { + powers = { + ...powers, + getVectorStore: makeGetVectorStore(vectorStore), + }; + } + + return powers; +} diff --git a/packages/nodejs/src/vat/powers/ollama.ts b/packages/nodejs/src/vat/powers/ollama.ts new file mode 100644 index 000000000..a60371807 --- /dev/null +++ b/packages/nodejs/src/vat/powers/ollama.ts @@ -0,0 +1,32 @@ +import { Ollama } from 'ollama'; + +/** + * Returns a promise that resolves when the ollama service is running, + * or rejects if the API is unreachable. + * + * @param host - The url to reach the local ollama server. + */ +export const ollamaOnline = async (host: string): Promise => { + const response = await (await fetch(host)).text(); + const expectation = 'Ollama is running'; + if (response !== expectation) { + throw new Error('Ollama not running', { cause: { host, response } }); + } +}; + +/** + * Ensure the ollama server is running and return a connection to it. + * + * @param param0 - The args. + * @param param0.host - The url to reach the local ollama server. + * @returns An Ollama API object. + * @throws If the ollama server cannot be reached at the provided host url. + */ +export default async function makeOllama({ + host, +}: { + host: string; +}): Promise { + await ollamaOnline(host); + return new Ollama({ host }); +} diff --git a/packages/nodejs/src/vat/powers/vector-store.ts b/packages/nodejs/src/vat/powers/vector-store.ts new file mode 100644 index 000000000..78461c279 --- /dev/null +++ b/packages/nodejs/src/vat/powers/vector-store.ts @@ -0,0 +1,36 @@ +import { OllamaEmbeddings } from '@langchain/ollama'; +import { MemoryVectorStore } from 'langchain/vectorstores/memory'; + +type Args = { + host: string; + model?: string; +}; + +type GetVectorStore = () => MemoryVectorStore; + +const DEFAULT_EMBED_MODEL = 'mxbai-embed-large-8k'; + +/** + * Make a function that returns a vector store. + * + * @param options0 - The options for the vector store. + * @param options0.host - The host to reach the local ollama server. + * @param options0.model - The model to use for the vector store. + * @returns A function that returns a vector store. + */ +export default function makeGetVectorStore({ + host, + model, +}: Args): GetVectorStore { + const embeddings = new OllamaEmbeddings({ + baseUrl: host, + model: model ?? DEFAULT_EMBED_MODEL, + }); + const vectorStore = new MemoryVectorStore(embeddings); + + // XXX Hardening the vectorStore renders it inoperational, so we wrap it in + // an arrow function which returns a soft vectorStore even after hardening. + const getVectorStore = (): MemoryVectorStore => vectorStore; + + return getVectorStore; +} diff --git a/packages/nodejs/src/vat/vat-worker.ts b/packages/nodejs/src/vat/vat-worker.ts index 96cecba92..61fb3b0ff 100644 --- a/packages/nodejs/src/vat/vat-worker.ts +++ b/packages/nodejs/src/vat/vat-worker.ts @@ -7,9 +7,11 @@ import { makeLogger } from '@ocap/utils'; import fs from 'node:fs/promises'; import url from 'node:url'; +import makePowers from './powers/make-powers.ts'; import { makeCommandStream } from './streams.ts'; -const vatId = process.env.NODE_VAT_ID as VatId; +const vatId: VatId = process.env.NODE_VAT_ID as VatId; +const documentRoot = process.env.NODE_DOCUMENT_ROOT as string; /* eslint-disable n/no-unsupported-features/node-builtins */ @@ -44,11 +46,27 @@ async function fetchBlob(blobURL: string): Promise { async function main(): Promise { const commandStream = makeCommandStream(); await commandStream.synchronize(); + + const ollamaUrl = 'http://localhost:11434'; + // eslint-disable-next-line no-void void new VatSupervisor({ id: vatId, commandStream, makeKVStore: makeSQLKVStore, + // XXX This makes duplicate powers, even for vats that don't need them >:[ + // Some method is necessary for designating the appropriate powers when the + // kernel is starting the vat. Running software doesn't need full isolation; + // only its access within the program must be attenuated by some tame facade. + makePowers: async () => + await makePowers({ + loadDocument: { root: documentRoot }, + ollama: { host: ollamaUrl }, + vectorStore: { + host: ollamaUrl, + model: (process.env.EMBED_MODEL as string) ?? undefined, + }, + }), fetchBlob, }); } diff --git a/packages/nodejs/test/e2e/kernel-worker.test.ts b/packages/nodejs/test/e2e/kernel-worker.test.ts index f93dd6daf..5041ce924 100644 --- a/packages/nodejs/test/e2e/kernel-worker.test.ts +++ b/packages/nodejs/test/e2e/kernel-worker.test.ts @@ -33,7 +33,7 @@ describe('Kernel Worker', () => { kernelPort.close(); } kernelPort = new NodeMessageChannel().port1; - kernel = await makeKernel(kernelPort); + kernel = await makeKernel({ port: kernelPort }); }); afterEach(async () => { diff --git a/packages/nodejs/test/workers/index.ts b/packages/nodejs/test/workers/index.ts new file mode 100644 index 000000000..0fd6a4a00 --- /dev/null +++ b/packages/nodejs/test/workers/index.ts @@ -0,0 +1,2 @@ +export const getTestWorkerFile = (name: string): string => + new URL(`./${name}.js`, import.meta.url).pathname; diff --git a/packages/nodejs/tsconfig.json b/packages/nodejs/tsconfig.json index 01dd4a8f0..e7ad0ab18 100644 --- a/packages/nodejs/tsconfig.json +++ b/packages/nodejs/tsconfig.json @@ -5,7 +5,6 @@ "baseUrl": "./", "isolatedModules": true, "lib": ["ES2022"], - "noEmit": true, "types": ["node", "ses", "vitest"] }, "references": [ @@ -19,11 +18,13 @@ "include": [ "../../vitest.config.packages.ts", "../../vitest.workspace.ts", + "../../types", "./src/**/*.ts", "./src/**/*-trusted-prelude.js", "./test/**/*.ts", + "./test/workers/*.js", "./vitest.config.ts", "./vitest.config.e2e.ts", - "./test/workers/*.js" + "./vitest.config.llm.ts" ] } diff --git a/yarn.lock b/yarn.lock index f36c62273..e9f6d61ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -451,6 +451,13 @@ __metadata: languageName: node linkType: hard +"@cfworker/json-schema@npm:^4.0.2": + version: 4.1.1 + resolution: "@cfworker/json-schema@npm:4.1.1" + checksum: 10/62fd08bb2e6b4f0fe7c2b8f8c19f17f94b6a34feba7f455f228898ab435eda8aae082fcf6b0fe8a235a72e0ec0041922fdcd4c526acc32d45084272f000c1af9 + languageName: node + linkType: hard + "@colors/colors@npm:1.5.0": version: 1.5.0 resolution: "@colors/colors@npm:1.5.0" @@ -1257,6 +1264,63 @@ __metadata: languageName: node linkType: hard +"@langchain/core@npm:^0.3.37": + version: 0.3.37 + resolution: "@langchain/core@npm:0.3.37" + dependencies: + "@cfworker/json-schema": "npm:^4.0.2" + ansi-styles: "npm:^5.0.0" + camelcase: "npm:6" + decamelize: "npm:1.2.0" + js-tiktoken: "npm:^1.0.12" + langsmith: "npm:>=0.2.8 <0.4.0" + mustache: "npm:^4.2.0" + p-queue: "npm:^6.6.2" + p-retry: "npm:4" + uuid: "npm:^10.0.0" + zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.22.3" + checksum: 10/84c492c806bb420fad79e5e09efe1ece120d7d90f5176c803df39216e965a16f46a3fde3c4af3613c2a31871d94ace31711e65fc2f9ec0368322cdac9c46e194 + languageName: node + linkType: hard + +"@langchain/ollama@npm:^0.1.5": + version: 0.1.5 + resolution: "@langchain/ollama@npm:0.1.5" + dependencies: + ollama: "npm:^0.5.9" + uuid: "npm:^10.0.0" + peerDependencies: + "@langchain/core": ">=0.2.21 <0.4.0" + checksum: 10/541e9a8bad78f07e81c6c3b60c1f8eb40cacca27debd50a231a69eaa98539b60fb57744d3227f2bb9525118b30d049818a51211b2b194d1e448e003e803b4013 + languageName: node + linkType: hard + +"@langchain/openai@npm:>=0.1.0 <0.5.0": + version: 0.4.2 + resolution: "@langchain/openai@npm:0.4.2" + dependencies: + js-tiktoken: "npm:^1.0.12" + openai: "npm:^4.77.0" + zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.22.3" + peerDependencies: + "@langchain/core": ">=0.3.29 <0.4.0" + checksum: 10/8be655ce71e33dfd62b8b25c9dedce1e30a29593b5e213e161763eee3f51366f988987049fff29a1a6123405586767af93d34e7723ed82f264d3cd2689dc7995 + languageName: node + linkType: hard + +"@langchain/textsplitters@npm:>=0.0.0 <0.2.0, @langchain/textsplitters@npm:^0.1.0": + version: 0.1.0 + resolution: "@langchain/textsplitters@npm:0.1.0" + dependencies: + js-tiktoken: "npm:^1.0.12" + peerDependencies: + "@langchain/core": ">=0.2.21 <0.4.0" + checksum: 10/87121ec5ad003834ca5b5d1cb6c949c0744df3411011546dedf7bc8e04e2572a9663aadf2d70c25acdf7364845e00c30c039b610ff19b3f15429adcd059838b3 + languageName: node + linkType: hard + "@lavamoat/aa@npm:^4.3.1": version: 4.3.1 resolution: "@lavamoat/aa@npm:4.3.1" @@ -2196,7 +2260,18 @@ __metadata: resolution: "@ocap/nodejs@workspace:packages/nodejs" dependencies: "@arethetypeswrong/cli": "npm:^0.17.3" + "@endo/base64": "npm:^1.0.9" + "@endo/eventual-send": "npm:^1.2.6" + "@endo/exo": "npm:^1.5.8" + "@endo/far": "npm:^1.1.9" + "@endo/marshal": "npm:^1.6.2" + "@endo/pass-style": "npm:^1.4.7" + "@endo/patterns": "npm:^1.4.8" "@endo/promise-kit": "npm:^1.1.6" + "@endo/stream": "npm:^1.2.6" + "@langchain/core": "npm:^0.3.37" + "@langchain/ollama": "npm:^0.1.5" + "@langchain/textsplitters": "npm:^0.1.0" "@metamask/auto-changelog": "npm:^4.0.0" "@metamask/eslint-config": "npm:^14.0.0" "@metamask/eslint-config-nodejs": "npm:^14.0.0" @@ -2224,7 +2299,9 @@ __metadata: eslint-plugin-n: "npm:^17.11.1" eslint-plugin-prettier: "npm:^5.2.1" eslint-plugin-promise: "npm:^7.1.0" + langchain: "npm:^0.3.15" node-gyp: "npm:^11.0.0" + ollama: "npm:^0.5.12" prettier: "npm:^3.3.3" rimraf: "npm:^6.0.1" ses: "npm:^1.9.0" @@ -3216,12 +3293,31 @@ __metadata: languageName: node linkType: hard +"@types/node-fetch@npm:^2.6.4": + version: 2.6.12 + resolution: "@types/node-fetch@npm:2.6.12" + dependencies: + "@types/node": "npm:*" + form-data: "npm:^4.0.0" + checksum: 10/8107c479da83a3114fcbfa882eba95ee5175cccb5e4dd53f737a96f2559ae6262f662176b8457c1656de09ec393cc7b20a266c077e4bfb21e929976e1cf4d0f9 + languageName: node + linkType: hard + "@types/node@npm:*, @types/node@npm:^22.13.1": - version: 22.13.5 - resolution: "@types/node@npm:22.13.5" + version: 22.13.10 + resolution: "@types/node@npm:22.13.10" dependencies: undici-types: "npm:~6.20.0" - checksum: 10/a69ec8dba36a58a93e3ec3709a6a362ca0cdd8443310bb5e43b0c1f560c57bcc120c96fabb301ef42c2901f46103adad5158b6923ea14e8e14a432af20a2bb24 + checksum: 10/57dc6a5e0110ca9edea8d7047082e649fa7fa813f79e4a901653b9174141c622f4336435648baced5b38d9f39843f404fa2d8d7a10981610da26066bc8caab48 + languageName: node + linkType: hard + +"@types/node@npm:^18.11.18": + version: 18.19.80 + resolution: "@types/node@npm:18.19.80" + dependencies: + undici-types: "npm:~5.26.4" + checksum: 10/05a54af019aae1fa7d7f8e6475071a7486cb8554f638894b5697ef1cad3b5ac6cbdd5fdfe3d8d70b3af7e56e0245b0033a986c00e1c09e80cb584db06e40c0ba languageName: node linkType: hard @@ -3285,6 +3381,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.0": + version: 0.12.0 + resolution: "@types/retry@npm:0.12.0" + checksum: 10/bbd0b88f4b3eba7b7acfc55ed09c65ef6f2e1bcb4ec9b4dca82c66566934351534317d294a770a7cc6c0468d5573c5350abab6e37c65f8ef254443e1b028e44d + languageName: node + linkType: hard + "@types/serve-handler@npm:^6": version: 6.1.4 resolution: "@types/serve-handler@npm:6.1.4" @@ -3322,6 +3425,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "@types/uuid@npm:10.0.0" + checksum: 10/e3958f8b0fe551c86c14431f5940c3470127293280830684154b91dc7eb3514aeb79fe3216968833cf79d4d1c67f580f054b5be2cd562bebf4f728913e73e944 + languageName: node + linkType: hard + "@types/webextension-polyfill@npm:^0": version: 0.12.2 resolution: "@types/webextension-polyfill@npm:0.12.2" @@ -3732,6 +3842,15 @@ __metadata: languageName: node linkType: hard +"agentkeepalive@npm:^4.2.1": + version: 4.6.0 + resolution: "agentkeepalive@npm:4.6.0" + dependencies: + humanize-ms: "npm:^1.2.1" + checksum: 10/80c546bd88dd183376d6a29e5598f117f380b1d567feb1de184241d6ece721e2bdd38f179a1674276de01780ccae229a38c60a77317e2f5ad2f1818856445bd7 + languageName: node + linkType: hard + "aggregate-error@npm:^3.0.0": version: 3.1.0 resolution: "aggregate-error@npm:3.1.0" @@ -4027,7 +4146,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.3.1": +"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 @@ -4274,7 +4393,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^6.3.0": +"camelcase@npm:6, camelcase@npm:^6.3.0": version: 6.3.0 resolution: "camelcase@npm:6.3.0" checksum: 10/8c96818a9076434998511251dcb2761a94817ea17dbdc37f47ac080bd088fc62c7369429a19e2178b993497132c8cbcf5cc1f44ba963e76782ba469c0474938d @@ -4603,6 +4722,15 @@ __metadata: languageName: node linkType: hard +"console-table-printer@npm:^2.12.1": + version: 2.12.1 + resolution: "console-table-printer@npm:2.12.1" + dependencies: + simple-wcswidth: "npm:^1.0.1" + checksum: 10/37ac91d3601aa6747d3a895487ec9271488c5dae9154745513b6bfbb74f46c414aa4d8e86197b915be9565d1dd2b38005466fa94814ff62b1a08c4e37d57b601 + languageName: node + linkType: hard + "content-disposition@npm:0.5.2": version: 0.5.2 resolution: "content-disposition@npm:0.5.2" @@ -4664,7 +4792,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -4792,6 +4920,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10/ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa + languageName: node + linkType: hard + "decimal.js@npm:^10.4.3": version: 10.5.0 resolution: "decimal.js@npm:10.5.0" @@ -5809,6 +5944,13 @@ __metadata: languageName: node linkType: hard +"eventemitter3@npm:^4.0.4": + version: 4.0.7 + resolution: "eventemitter3@npm:4.0.7" + checksum: 10/8030029382404942c01d0037079f1b1bc8fed524b5849c237b80549b01e2fc49709e1d0c557fa65ca4498fc9e24cff1475ef7b855121fcc15f9d61f93e282346 + languageName: node + linkType: hard + "eventemitter3@npm:^5.0.1": version: 5.0.1 resolution: "eventemitter3@npm:5.0.1" @@ -6087,17 +6229,24 @@ __metadata: languageName: node linkType: hard -"foreground-child@npm:^3.1.0": - version: 3.3.1 - resolution: "foreground-child@npm:3.3.1" +"foreground-child@npm:3.1.1": + version: 3.1.1 + resolution: "foreground-child@npm:3.1.1" dependencies: - cross-spawn: "npm:^7.0.6" + cross-spawn: "npm:^7.0.0" signal-exit: "npm:^4.0.1" - checksum: 10/427b33f997a98073c0424e5c07169264a62cda806d8d2ded159b5b903fdfc8f0a1457e06b5fc35506497acb3f1e353f025edee796300209ac6231e80edece835 + checksum: 10/087edd44857d258c4f73ad84cb8df980826569656f2550c341b27adf5335354393eec24ea2fabd43a253233fb27cee177ebe46bd0b7ea129c77e87cb1e9936fb languageName: node linkType: hard -"form-data@npm:^4.0.0": +"form-data-encoder@npm:1.7.2": + version: 1.7.2 + resolution: "form-data-encoder@npm:1.7.2" + checksum: 10/227bf2cea083284411fd67472ccc22f5cb354ca92c00690e11ff5ed942d993c13ac99dea365046306200f8bd71e1a7858d2d99e236de694b806b1f374a4ee341 + languageName: node + linkType: hard + +"form-data@npm:4.0.2": version: 4.0.2 resolution: "form-data@npm:4.0.2" dependencies: @@ -6109,6 +6258,16 @@ __metadata: languageName: node linkType: hard +"formdata-node@npm:^4.3.2": + version: 4.4.1 + resolution: "formdata-node@npm:4.4.1" + dependencies: + node-domexception: "npm:1.0.0" + web-streams-polyfill: "npm:4.0.0-beta.3" + checksum: 10/29622f75533107c1bbcbe31fda683e6a55859af7f48ec354a9800591ce7947ed84cd3ef2b2fcb812047a884f17a1bac75ce098ffc17e23402cd373e49c1cd335 + languageName: node + linkType: hard + "fs-constants@npm:^1.0.0": version: 1.0.0 resolution: "fs-constants@npm:1.0.0" @@ -6651,6 +6810,15 @@ __metadata: languageName: node linkType: hard +"humanize-ms@npm:^1.2.1": + version: 1.2.1 + resolution: "humanize-ms@npm:1.2.1" + dependencies: + ms: "npm:^2.0.0" + checksum: 10/9c7a74a2827f9294c009266c82031030eae811ca87b0da3dceb8d6071b9bde22c9f3daef0469c3c533cc67a97d8a167cd9fc0389350e5f415f61a79b171ded16 + languageName: node + linkType: hard + "iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" @@ -7291,6 +7459,15 @@ __metadata: languageName: node linkType: hard +"js-tiktoken@npm:^1.0.12": + version: 1.0.16 + resolution: "js-tiktoken@npm:1.0.16" + dependencies: + base64-js: "npm:^1.5.1" + checksum: 10/f56717315070baa2629d031117212289621782c3d428edf4f7a78ea41ad60cf1d45585dc8531271f4952d58166f5cc704e024f39d79f0af75f6fc63d6ad011d5 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -7451,6 +7628,13 @@ __metadata: languageName: node linkType: hard +"jsonpointer@npm:^5.0.1": + version: 5.0.1 + resolution: "jsonpointer@npm:5.0.1" + checksum: 10/0b40f712900ad0c846681ea2db23b6684b9d5eedf55807b4708c656f5894b63507d0e28ae10aa1bddbea551241035afe62b6df0800fc94c2e2806a7f3adecd7c + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0": version: 3.3.5 resolution: "jsx-ast-utils@npm:3.3.5" @@ -7472,6 +7656,97 @@ __metadata: languageName: node linkType: hard +"langchain@npm:^0.3.15": + version: 0.3.15 + resolution: "langchain@npm:0.3.15" + dependencies: + "@langchain/openai": "npm:>=0.1.0 <0.5.0" + "@langchain/textsplitters": "npm:>=0.0.0 <0.2.0" + js-tiktoken: "npm:^1.0.12" + js-yaml: "npm:^4.1.0" + jsonpointer: "npm:^5.0.1" + langsmith: "npm:>=0.2.8 <0.4.0" + openapi-types: "npm:^12.1.3" + p-retry: "npm:4" + uuid: "npm:^10.0.0" + yaml: "npm:^2.2.1" + zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.22.3" + peerDependencies: + "@langchain/anthropic": "*" + "@langchain/aws": "*" + "@langchain/cerebras": "*" + "@langchain/cohere": "*" + "@langchain/core": ">=0.2.21 <0.4.0" + "@langchain/deepseek": "*" + "@langchain/google-genai": "*" + "@langchain/google-vertexai": "*" + "@langchain/google-vertexai-web": "*" + "@langchain/groq": "*" + "@langchain/mistralai": "*" + "@langchain/ollama": "*" + axios: "*" + cheerio: "*" + handlebars: ^4.7.8 + peggy: ^3.0.2 + typeorm: "*" + peerDependenciesMeta: + "@langchain/anthropic": + optional: true + "@langchain/aws": + optional: true + "@langchain/cerebras": + optional: true + "@langchain/cohere": + optional: true + "@langchain/deepseek": + optional: true + "@langchain/google-genai": + optional: true + "@langchain/google-vertexai": + optional: true + "@langchain/google-vertexai-web": + optional: true + "@langchain/groq": + optional: true + "@langchain/mistralai": + optional: true + "@langchain/ollama": + optional: true + axios: + optional: true + cheerio: + optional: true + handlebars: + optional: true + peggy: + optional: true + typeorm: + optional: true + checksum: 10/a4e4d8b024a997376319104ca3a892783a16b6a787f97c250008029c883b8fce8bc1d50e029bcb86e626db1828e7e2cfc22851a2c79a800884639563e9501e3b + languageName: node + linkType: hard + +"langsmith@npm:>=0.2.8 <0.4.0": + version: 0.3.4 + resolution: "langsmith@npm:0.3.4" + dependencies: + "@types/uuid": "npm:^10.0.0" + chalk: "npm:^4.1.2" + console-table-printer: "npm:^2.12.1" + p-queue: "npm:^6.6.2" + p-retry: "npm:4" + semver: "npm:^7.6.3" + uuid: "npm:^10.0.0" + peerDependencies: + openai: "*" + peerDependenciesMeta: + openai: + optional: true + checksum: 10/1c74d42a113b043560282176b45aef02a02585c864a4fb732acc655c5566e575daa70f0f93fe815beb137ad8bda6b73cd7bfb4917ead7d9984deedf29d207532 + languageName: node + linkType: hard + "less@npm:^4.2.0": version: 4.2.2 resolution: "less@npm:4.2.2" @@ -8131,7 +8406,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.1.1, ms@npm:^2.1.3": +"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: 10/aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -8184,6 +8459,15 @@ __metadata: languageName: node linkType: hard +"mustache@npm:^4.2.0": + version: 4.2.0 + resolution: "mustache@npm:4.2.0" + bin: + mustache: bin/mustache + checksum: 10/6e668bd5803255ab0779c3983b9412b5c4f4f90e822230e0e8f414f5449ed7a137eed29430e835aa689886f663385cfe05f808eb34b16e1f3a95525889b05cd3 + languageName: node + linkType: hard + "mute-stream@npm:^2.0.0": version: 2.0.0 resolution: "mute-stream@npm:2.0.0" @@ -8269,6 +8553,13 @@ __metadata: languageName: node linkType: hard +"node-domexception@npm:1.0.0": + version: 1.0.0 + resolution: "node-domexception@npm:1.0.0" + checksum: 10/e332522f242348c511640c25a6fc7da4f30e09e580c70c6b13cb0be83c78c3e71c8d4665af2527e869fc96848924a4316ae7ec9014c091e2156f41739d4fa233 + languageName: node + linkType: hard + "node-emoji@npm:^2.2.0": version: 2.2.0 resolution: "node-emoji@npm:2.2.0" @@ -8281,6 +8572,20 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^2.6.7": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10/b24f8a3dc937f388192e59bcf9d0857d7b6940a2496f328381641cb616efccc9866e89ec43f2ec956bbd6c3d3ee05524ce77fe7b29ccd34692b3a16f237d6676 + languageName: node + linkType: hard + "node-gyp@npm:^10.0.0": version: 10.3.1 resolution: "node-gyp@npm:10.3.1" @@ -8522,6 +8827,15 @@ __metadata: languageName: node linkType: hard +"ollama@npm:^0.5.12, ollama@npm:^0.5.9": + version: 0.5.12 + resolution: "ollama@npm:0.5.12" + dependencies: + whatwg-fetch: "npm:^3.6.20" + checksum: 10/90636b7f1c68ed1e0e992e33da4320536db61b002b036d809d8bed417d5c604e174c9bc4311562e8438c67f9dadf6550c4b76cd5a869a102db66542c43b16727 + languageName: node + linkType: hard + "once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -8558,6 +8872,38 @@ __metadata: languageName: node linkType: hard +"openai@npm:^4.77.0": + version: 4.82.0 + resolution: "openai@npm:4.82.0" + dependencies: + "@types/node": "npm:^18.11.18" + "@types/node-fetch": "npm:^2.6.4" + abort-controller: "npm:^3.0.0" + agentkeepalive: "npm:^4.2.1" + form-data-encoder: "npm:1.7.2" + formdata-node: "npm:^4.3.2" + node-fetch: "npm:^2.6.7" + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + bin: + openai: bin/cli + checksum: 10/1d2d9c11ebfd3d1dac3c9feead20517d4d1f22b3f16e9dc71d0bf54852e5323ddf5f9de81f08e90ccf0520192aa54a9e6779b947293cd7dc8453872784576167 + languageName: node + linkType: hard + +"openapi-types@npm:^12.1.3": + version: 12.1.3 + resolution: "openapi-types@npm:12.1.3" + checksum: 10/9d1d7ed848622b63d0a4c3f881689161b99427133054e46b8e3241e137f1c78bb0031c5d80b420ee79ac2e91d2e727ffd6fc13c553d1b0488ddc8ad389dcbef8 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -8590,6 +8936,13 @@ __metadata: languageName: node linkType: hard +"p-finally@npm:^1.0.0": + version: 1.0.0 + resolution: "p-finally@npm:1.0.0" + checksum: 10/93a654c53dc805dd5b5891bab16eb0ea46db8f66c4bfd99336ae929323b1af2b70a8b0654f8f1eae924b2b73d037031366d645f1fd18b3d30cbd15950cc4b1d4 + languageName: node + linkType: hard + "p-limit@npm:^3.0.2": version: 3.1.0 resolution: "p-limit@npm:3.1.0" @@ -8624,6 +8977,35 @@ __metadata: languageName: node linkType: hard +"p-queue@npm:^6.6.2": + version: 6.6.2 + resolution: "p-queue@npm:6.6.2" + dependencies: + eventemitter3: "npm:^4.0.4" + p-timeout: "npm:^3.2.0" + checksum: 10/60fe227ffce59fbc5b1b081305b61a2f283ff145005853702b7d4d3f99a0176bd21bb126c99a962e51fe1e01cb8aa10f0488b7bbe73b5dc2e84b5cc650b8ffd2 + languageName: node + linkType: hard + +"p-retry@npm:4": + version: 4.6.2 + resolution: "p-retry@npm:4.6.2" + dependencies: + "@types/retry": "npm:0.12.0" + retry: "npm:^0.13.1" + checksum: 10/45c270bfddaffb4a895cea16cb760dcc72bdecb6cb45fef1971fa6ea2e91ddeafddefe01e444ac73e33b1b3d5d29fb0dd18a7effb294262437221ddc03ce0f2e + languageName: node + linkType: hard + +"p-timeout@npm:^3.2.0": + version: 3.2.0 + resolution: "p-timeout@npm:3.2.0" + dependencies: + p-finally: "npm:^1.0.0" + checksum: 10/3dd0eaa048780a6f23e5855df3dd45c7beacff1f820476c1d0d1bcd6648e3298752ba2c877aa1c92f6453c7dd23faaf13d9f5149fc14c0598a142e2c5e8d649c + languageName: node + linkType: hard + "package-json-from-dist@npm:^1.0.0": version: 1.0.1 resolution: "package-json-from-dist@npm:1.0.1" @@ -9497,6 +9879,13 @@ __metadata: languageName: node linkType: hard +"retry@npm:^0.13.1": + version: 0.13.1 + resolution: "retry@npm:0.13.1" + checksum: 10/6125ec2e06d6e47e9201539c887defba4e47f63471db304c59e4b82fc63c8e89ca06a77e9d34939a9a42a76f00774b2f46c0d4a4cbb3e287268bd018ed69426d + languageName: node + linkType: hard + "reusify@npm:^1.0.4": version: 1.1.0 resolution: "reusify@npm:1.1.0" @@ -9969,6 +10358,13 @@ __metadata: languageName: node linkType: hard +"simple-wcswidth@npm:^1.0.1": + version: 1.0.1 + resolution: "simple-wcswidth@npm:1.0.1" + checksum: 10/75b1a5a941f516b829e3ae2dd7d15aa03800b38428e3f0272ac718776243e148f3dda0127b6dbd466a0a1e689f42911d64ca30665724691638721c3497015474 + languageName: node + linkType: hard + "sirv@npm:^3.0.1": version: 3.0.1 resolution: "sirv@npm:3.0.1" @@ -10654,6 +11050,13 @@ __metadata: languageName: node linkType: hard +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10/8f1f5aa6cb232f9e1bdc86f485f916b7aa38caee8a778b378ffec0b70d9307873f253f5cbadbe2955ece2ac5c83d0dc14a77513166ccd0a0c7fe197e21396695 + languageName: node + linkType: hard + "ts-api-utils@npm:^2.0.1": version: 2.0.1 resolution: "ts-api-utils@npm:2.0.1" @@ -10932,6 +11335,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~5.26.4": + version: 5.26.5 + resolution: "undici-types@npm:5.26.5" + checksum: 10/0097779d94bc0fd26f0418b3a05472410408877279141ded2bd449167be1aed7ea5b76f756562cb3586a07f251b90799bab22d9019ceba49c037c76445f7cddd + languageName: node + linkType: hard + "undici-types@npm:~6.20.0": version: 6.20.0 resolution: "undici-types@npm:6.20.0" @@ -11050,6 +11460,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^10.0.0": + version: 10.0.0 + resolution: "uuid@npm:10.0.0" + bin: + uuid: dist/bin/uuid + checksum: 10/35aa60614811a201ff90f8ca5e9ecb7076a75c3821e17f0f5ff72d44e36c2d35fcbc2ceee9c4ac7317f4cc41895da30e74f3885e30313bee48fda6338f250538 + languageName: node + linkType: hard + "uuid@npm:^9.0.1": version: 9.0.1 resolution: "uuid@npm:9.0.1" @@ -11363,6 +11782,20 @@ __metadata: languageName: node linkType: hard +"web-streams-polyfill@npm:4.0.0-beta.3": + version: 4.0.0-beta.3 + resolution: "web-streams-polyfill@npm:4.0.0-beta.3" + checksum: 10/dcdef67de57d83008f9dc330662b65ba4497315555dd0e4e7bcacb132ffdf8a830eaab8f74ad40a4a44f542461f51223f406e2a446ece1cc29927859b1405853 + languageName: node + linkType: hard + +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10/b65b9f8d6854572a84a5c69615152b63371395f0c5dcd6729c45789052296df54314db2bc3e977df41705eacb8bc79c247cee139a63fa695192f95816ed528ad + languageName: node + linkType: hard + "webidl-conversions@npm:^7.0.0": version: 7.0.0 resolution: "webidl-conversions@npm:7.0.0" @@ -11379,6 +11812,13 @@ __metadata: languageName: node linkType: hard +"whatwg-fetch@npm:^3.6.20": + version: 3.6.20 + resolution: "whatwg-fetch@npm:3.6.20" + checksum: 10/2b4ed92acd6a7ad4f626a6cb18b14ec982bbcaf1093e6fe903b131a9c6decd14d7f9c9ca3532663c2759d1bdf01d004c77a0adfb2716a5105465c20755a8c57c + languageName: node + linkType: hard + "whatwg-mimetype@npm:^4.0.0": version: 4.0.0 resolution: "whatwg-mimetype@npm:4.0.0" @@ -11396,6 +11836,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10/f95adbc1e80820828b45cc671d97da7cd5e4ef9deb426c31bcd5ab00dc7103042291613b3ef3caec0a2335ed09e0d5ed026c940755dbb6d404e2b27f940fdf07 + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" @@ -11651,7 +12101,7 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.6.1, yaml@npm:^2.7.0": +"yaml@npm:^2.2.1, yaml@npm:^2.6.1, yaml@npm:^2.7.0": version: 2.7.0 resolution: "yaml@npm:2.7.0" bin: @@ -11724,3 +12174,19 @@ __metadata: checksum: 10/563fbec88bce9716d1044bc98c96c329e1d7a7c503e6f1af68f1ff914adc3ba55ce953c871395e2efecad329f85f1632f51a99c362032940321ff80c42a6f74d languageName: node linkType: hard + +"zod-to-json-schema@npm:^3.22.3": + version: 3.24.1 + resolution: "zod-to-json-schema@npm:3.24.1" + peerDependencies: + zod: ^3.24.1 + checksum: 10/d31fd05b67b428d8e0d5ecad2c3e80a1c2fc370e4c22f9111ffd11cbe05cfcab00f3228f84295830952649d15ea4494ef42c2ee1cbe723c865b13f4cf2b80c09 + languageName: node + linkType: hard + +"zod@npm:^3.22.4": + version: 3.24.1 + resolution: "zod@npm:3.24.1" + checksum: 10/54e25956495dec22acb9399c168c6ba657ff279801a7fcd0530c414d867f1dcca279335e160af9b138dd70c332e17d548be4bc4d2f7eaf627dead50d914fec27 + languageName: node + linkType: hard