From 2fe91d5a4aa7725f3164898a6a553388a0f0cedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Rodr=C3=ADguez?= Date: Tue, 21 Jan 2025 12:09:39 +0100 Subject: [PATCH] Universal compiler (#12) * Universal compiler * wip * fix style issues --- README.md | 5 + eslint.config.mjs | 1 + examples/lib.ts | 31 ++++++ examples/rpc.ts | 148 ++++++++++++++++++++++++++ examples/start.ts | 13 --- package.json | 9 +- pnpm-lock.yaml | 141 +++++++++++++++++++++++++ rollup.config.rpc.mjs | 57 ++++++++++ src/compiler/chain-serialize.test.ts | 38 +++++-- src/compiler/chain.ts | 15 ++- src/compiler/deserializeChain.ts | 26 +++-- src/compiler/scan.ts | 10 +- src/index.rpc.ts | 3 + src/providers/index.ts | 9 +- src/rpc/index.ts | 1 + src/rpc/procedures.ts | 63 +++++++++++ src/rpc/server.ts | 150 +++++++++++++++++++++++++++ src/rpc/types.ts | 46 ++++++++ 18 files changed, 717 insertions(+), 49 deletions(-) create mode 100644 examples/lib.ts create mode 100644 examples/rpc.ts delete mode 100644 examples/start.ts create mode 100644 rollup.config.rpc.mjs create mode 100644 src/index.rpc.ts create mode 100644 src/rpc/index.ts create mode 100644 src/rpc/procedures.ts create mode 100644 src/rpc/server.ts create mode 100644 src/rpc/types.ts diff --git a/README.md b/README.md index 291ddf6..59ecfa2 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,8 @@ This is just a small example of what PromptL can do. It is a powerful tool that ## Links [Website](https://promptl.ai/) | [Documentation](https://docs.latitude.so/promptl/getting-started/introduction) + +## Development + +- To build the JavaScript library, run `pnpm build:lib` +- To build the universal WASM module with RPC, first install [`javy`](https://github.com/bytecodealliance/javy/releases) and then run `pnpm build:rpc` diff --git a/eslint.config.mjs b/eslint.config.mjs index 3f397e1..1f76a26 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,6 +30,7 @@ export default [ globals: { ...globals.node, ...globals.browser, + Javy: 'readonly', }, parser: tsParser, diff --git a/examples/lib.ts b/examples/lib.ts new file mode 100644 index 0000000..d0aefe3 --- /dev/null +++ b/examples/lib.ts @@ -0,0 +1,31 @@ +// Run `pnpm build:lib` before running this example + +import assert from 'node:assert' +import { inspect } from 'node:util' +import { Chain } from '../dist/index.js' + +const prompt = ` + + + You are a helpful assistant. + + + Say hello. + + + + + Now say goodbye. + + +` + +const chain = new Chain({ prompt }) +let conversation = await chain.step() +conversation = await chain.step('Hello!') +conversation = await chain.step('Goodbye!') + +assert(chain.completed) +assert(conversation.completed) + +console.log(inspect(conversation.messages, { depth: null })) diff --git a/examples/rpc.ts b/examples/rpc.ts new file mode 100644 index 0000000..0636548 --- /dev/null +++ b/examples/rpc.ts @@ -0,0 +1,148 @@ +// Run `pnpm build:rpc` before running this example + +import assert from 'node:assert' +import { FileHandle, mkdir, open, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { inspect } from 'node:util' +import { WASI } from 'node:wasi' + +const PROMPTL_WASM_PATH = './dist/promptl.wasm' + +const prompt = ` + + + You are a helpful assistant. + + + Say hello. + + + + + Now say goodbye. + + +` +let chain, conversation +chain = await createChain(prompt); +({ chain, ...conversation } = await stepChain(chain)); +({ chain, ...conversation } = await stepChain(chain, 'Hello!')); +({ chain, ...conversation } = await stepChain(chain, 'Goodbye!')); + +assert(chain.completed) +assert(conversation.completed) + +console.log(inspect(conversation.messages, { depth: null })) + +// Utility functions + +async function createChain(prompt: string): Promise { + return await execute([ + { + id: 1, + procedure: 'createChain', + parameters: { + prompt: prompt, + }, + }, + ]).then((result) => result[0]!.value) +} + +async function stepChain(chain: any, response?: any): Promise { + return await execute([ + { + id: 1, + procedure: 'stepChain', + parameters: { + chain: chain, + response: response, + }, + }, + ]).then((result) => result[0]!.value) +} + +async function execute(data: any): Promise { + const dir = join(tmpdir(), 'promptl') + const stdin_path = join(dir, 'stdin') + const stdout_path = join(dir, 'stdout') + const stderr_path = join(dir, 'stderr') + + await mkdir(dir, { recursive: true }) + await writeFile(stdin_path, '') + await writeFile(stdout_path, '') + await writeFile(stderr_path, '') + + let stdin: FileHandle | undefined + let stdout: FileHandle | undefined + let stderr: FileHandle | undefined + + let wasmStdin: FileHandle | undefined + let wasmStdout: FileHandle | undefined + let wasmStderr: FileHandle | undefined + + try { + stdin = await open(stdin_path, 'w') + stdout = await open(stdout_path, 'r') + stderr = await open(stderr_path, 'r') + + wasmStdin = await open(stdin_path, 'r') + wasmStdout = await open(stdout_path, 'w') + wasmStderr = await open(stderr_path, 'w') + + const wasi = new WASI({ + version: 'preview1', + args: [], + env: {}, + stdin: wasmStdin.fd, + stdout: wasmStdout.fd, + stderr: wasmStderr.fd, + returnOnExit: true, + }) + + const bytes = await readFile(PROMPTL_WASM_PATH) + WebAssembly.validate(bytes) + const module = await WebAssembly.compile(bytes) + const instance = await WebAssembly.instantiate( + module, + wasi.getImportObject(), + ) + + await send(stdin, data) + + wasi.start(instance) + + const [out, err] = await Promise.all([receive(stdout), receive(stderr)]) + if (err) throw new Error(err) + + return out + } finally { + await Promise.all([ + stdin?.close(), + stdout?.close(), + stderr?.close(), + wasmStdin?.close(), + wasmStdout?.close(), + wasmStderr?.close(), + ]) + } +} + +async function send(file: FileHandle, data: any) { + await writeFile(file, JSON.stringify(data) + '\n', { + encoding: 'utf8', + flush: true, + }) +} + +async function receive(file: FileHandle): Promise { + const data = await readFile(file, { + encoding: 'utf8', + }).then((data) => data.trim()) + + try { + return JSON.parse(data) + } catch { + return data + } +} diff --git a/examples/start.ts b/examples/start.ts deleted file mode 100644 index 925831b..0000000 --- a/examples/start.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Chain } from '../dist/index.js' - -const prompt = ` - - Hello world! - -` - -const chain = new Chain({ prompt }) - -const step = await chain.step() - -console.log(step) diff --git a/package.json b/package.json index 2c5b21b..9228f0f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ ], "scripts": { "dev": "rollup -c -w", - "build": "rollup -c", + "build:lib": "rollup -c", + "build:rpc": "rollup -c rollup.config.rpc.mjs", "test": "vitest run", "test:watch": "vitest", "prettier": "prettier --write src/**/*.ts", @@ -35,24 +36,28 @@ "dependencies": { "acorn": "^8.9.0", "code-red": "^1.0.3", + "fast-sha256": "^1.3.0", "locate-character": "^3.0.0", "yaml": "^2.4.5", "zod": "^3.23.8" }, "devDependencies": { - "eslint": "^9.17.0", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.17.0", "@rollup/plugin-alias": "^5.1.0", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.6", "@types/estree": "^1.0.1", "@types/node": "^20.12.12", "@typescript-eslint/eslint-plugin": "^8.19.0", + "eslint": "^9.17.0", "eslint-plugin-prettier": "^5.2.1", "globals": "^15.14.0", "prettier": "^3.4.2", "rollup": "^4.10.0", "rollup-plugin-dts": "^6.1.1", + "rollup-plugin-execute": "^1.1.1", "tslib": "^2.8.1", "tsx": "^4.19.2", "typescript": "^5.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb43c31..4500b2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: code-red: specifier: ^1.0.3 version: 1.0.4 + fast-sha256: + specifier: ^1.3.0 + version: 1.3.0 locate-character: specifier: ^3.0.0 version: 3.0.0 @@ -33,6 +36,12 @@ importers: '@rollup/plugin-alias': specifier: ^5.1.0 version: 5.1.1(rollup@4.27.4) + '@rollup/plugin-commonjs': + specifier: ^25.0.7 + version: 25.0.8(rollup@4.27.4) + '@rollup/plugin-node-resolve': + specifier: ^15.2.3 + version: 15.3.1(rollup@4.27.4) '@rollup/plugin-typescript': specifier: ^11.1.6 version: 11.1.6(rollup@4.27.4)(tslib@2.8.1)(typescript@5.7.2) @@ -63,6 +72,9 @@ importers: rollup-plugin-dts: specifier: ^6.1.1 version: 6.1.1(rollup@4.27.4)(typescript@5.7.2) + rollup-plugin-execute: + specifier: ^1.1.1 + version: 1.1.1 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -454,6 +466,24 @@ packages: rollup: optional: true + '@rollup/plugin-commonjs@25.0.8': + resolution: {integrity: sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-typescript@11.1.6': resolution: {integrity: sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==} engines: {node: '>=14.0.0'} @@ -578,6 +608,9 @@ packages: '@types/node@20.17.8': resolution: {integrity: sha512-ahz2g6/oqbKalW9sPv6L2iRbhLnojxjYWspAqhjvqSWBgGebEJT5GvRmk0QXPj3sbC6rU0GTQjPLQkmR8CObvA==} + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@typescript-eslint/eslint-plugin@8.19.0': resolution: {integrity: sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -713,6 +746,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -739,6 +775,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -839,6 +879,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fastq@1.18.0: resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==} @@ -861,6 +904,9 @@ packages: flatted@3.3.2: resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -887,6 +933,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -922,6 +973,13 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + is-core-module@2.15.1: resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} engines: {node: '>= 0.4'} @@ -934,10 +992,16 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -1012,6 +1076,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -1034,6 +1102,9 @@ packages: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -1149,6 +1220,9 @@ packages: rollup: ^3.29.4 || ^4 typescript: ^4.5 || ^5.0 + rollup-plugin-execute@1.1.1: + resolution: {integrity: sha512-isCNR/VrwlEfWJMwsnmt5TBRod8dW1IjVRxcXCBrxDmVTeA1IXjzeLSS3inFBmRD7KDPlo38KSb2mh5v5BoWgA==} + rollup@4.27.4: resolution: {integrity: sha512-RLKxqHEMjh/RGLsDxAEsaLO3mWgyoU6x9w6n1ikAzet4B3gI2/3yP6PWY2p9QzRTh6MfEIXB3MwsOY0Iv3vNrw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1336,6 +1410,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yaml@2.6.1: resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==} engines: {node: '>= 14'} @@ -1583,6 +1660,27 @@ snapshots: optionalDependencies: rollup: 4.27.4 + '@rollup/plugin-commonjs@25.0.8(rollup@4.27.4)': + dependencies: + '@rollup/pluginutils': 5.1.3(rollup@4.27.4) + commondir: 1.0.1 + estree-walker: 2.0.2 + glob: 8.1.0 + is-reference: 1.2.1 + magic-string: 0.30.14 + optionalDependencies: + rollup: 4.27.4 + + '@rollup/plugin-node-resolve@15.3.1(rollup@4.27.4)': + dependencies: + '@rollup/pluginutils': 5.1.3(rollup@4.27.4) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.8 + optionalDependencies: + rollup: 4.27.4 + '@rollup/plugin-typescript@11.1.6(rollup@4.27.4)(tslib@2.8.1)(typescript@5.7.2)': dependencies: '@rollup/pluginutils': 5.1.3(rollup@4.27.4) @@ -1664,6 +1762,8 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/resolve@1.20.2': {} + '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.17.0)(typescript@5.7.2))(eslint@9.17.0)(typescript@5.7.2)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -1849,6 +1949,8 @@ snapshots: color-name@1.1.4: {} + commondir@1.0.1: {} + concat-map@0.0.1: {} confbox@0.1.8: {} @@ -1869,6 +1971,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + diff-sequences@29.6.3: {} esbuild@0.21.5: @@ -2033,6 +2137,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fastq@1.18.0: dependencies: reusify: 1.0.4 @@ -2057,6 +2163,8 @@ snapshots: flatted@3.3.2: {} + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true @@ -2078,6 +2186,14 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + globals@14.0.0: {} globals@15.14.0: {} @@ -2101,6 +2217,13 @@ snapshots: imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + is-core-module@2.15.1: dependencies: hasown: 2.0.2 @@ -2111,8 +2234,14 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-module@1.0.0: {} + is-number@7.0.0: {} + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.6 + is-reference@3.0.3: dependencies: '@types/estree': 1.0.6 @@ -2181,6 +2310,10 @@ snapshots: dependencies: brace-expansion: 1.1.11 + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.1 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.1 @@ -2202,6 +2335,10 @@ snapshots: dependencies: path-key: 4.0.0 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@6.0.0: dependencies: mimic-fn: 4.0.0 @@ -2307,6 +2444,8 @@ snapshots: optionalDependencies: '@babel/code-frame': 7.26.2 + rollup-plugin-execute@1.1.1: {} + rollup@4.27.4: dependencies: '@types/estree': 1.0.6 @@ -2483,6 +2622,8 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + yaml@2.6.1: {} yocto-queue@0.1.0: {} diff --git a/rollup.config.rpc.mjs b/rollup.config.rpc.mjs new file mode 100644 index 0000000..fe05718 --- /dev/null +++ b/rollup.config.rpc.mjs @@ -0,0 +1,57 @@ +import commonjs from '@rollup/plugin-commonjs' +import nodeResolve from '@rollup/plugin-node-resolve' +import typescript from '@rollup/plugin-typescript' +import execute from 'rollup-plugin-execute' + +/** + * We have a internal circular dependency in the compiler, + * which is intentional. We think in this case Rollup is too noisy. + * + * @param {import('rollup').RollupLog} warning + * @returns {boolean} + */ +function isInternalCircularDependency(warning) { + return ( + warning.code == 'CIRCULAR_DEPENDENCY' && + warning.message.includes('src/compiler') && + !warning.message.includes('node_modules') + ) +} + +/** @type {import('rollup').RollupOptions} */ +export default { + onwarn: (warning, warn) => { + if (!isInternalCircularDependency(warning)) warn(warning) + }, + input: 'src/index.rpc.ts', + output: [ + { + file: 'dist/promptl.js', + format: 'es', + }, + ], + plugins: [ + nodeResolve({ + preferBuiltins: true, + }), + commonjs(), + typescript({ + noEmit: true, + tsconfig: './tsconfig.json', + exclude: ['**/__tests__', '**/*.test.ts'], + }), + execute([ + [ + 'javy build', + '-C dynamic=n', + '-C source-compression=y', + '-J javy-stream-io=y', + '-J simd-json-builtins=y', + '-J text-encoding=y', + '-J event-loop=y', + '-o dist/promptl.wasm', + 'dist/promptl.js', + ].join(' '), + ]), + ], +} diff --git a/src/compiler/chain-serialize.test.ts b/src/compiler/chain-serialize.test.ts index da77470..4fa7eec 100644 --- a/src/compiler/chain-serialize.test.ts +++ b/src/compiler/chain-serialize.test.ts @@ -1,13 +1,12 @@ -import { getExpectedError } from '$promptl/test/helpers' import { describe, expect, it } from 'vitest' -import { Chain } from './chain' -import { removeCommonIndent } from './utils' import { Adapters } from '$promptl/providers' import { MessageRole } from '$promptl/types' +import { Chain } from './chain' +import { removeCommonIndent } from './utils' describe('serialize chain', async () => { - it('fails when trying to serialize without running step', async () => { + it('serialize without running step', async () => { const prompt = removeCommonIndent(` Before step @@ -18,12 +17,22 @@ describe('serialize chain', async () => { `) const chain = new Chain({ prompt, adapter: Adapters.default }) + const serialized = chain.serialize() - const action = () => chain.serialize() - const error = await getExpectedError(action, Error) - expect(error.message).toBe( - 'The chain has not started yet. You must call `step` at least once before calling `serialize`.', - ) + expect(serialized).toEqual({ + rawText: prompt, + scope: { + pointers: {}, + stash: [], + }, + completed: false, + didStart: false, + adapterType: 'default', + compilerOptions: {}, + globalConfig: undefined, + ast: expect.any(Object), + globalMessages: [], + }) }) it('serialize with single step', async () => { @@ -43,12 +52,15 @@ describe('serialize chain', async () => { }) await chain.step() const serialized = chain.serialize() + expect(serialized).toEqual({ rawText: prompt, scope: { pointers: { foo: 0 }, stash: ['foo'], }, + completed: false, + didStart: true, adapterType: 'default', compilerOptions: {}, globalConfig: { @@ -79,16 +91,18 @@ describe('serialize chain', async () => { `) const chain = new Chain({ prompt, adapter: Adapters.openai }) - await chain.step() await chain.step('First step response') const serialized = chain.serialize() + expect(serialized).toEqual({ rawText: prompt, scope: { pointers: { foo: 0 }, stash: [6], }, + completed: false, + didStart: true, adapterType: 'openai', compilerOptions: { includeSourceMap: false }, globalConfig: undefined, @@ -113,13 +127,15 @@ describe('serialize chain', async () => { adapter: Adapters.default, defaultRole: MessageRole.user, }) - await chain.step() const serialized = chain.serialize() + expect(serialized).toEqual({ rawText: prompt, adapterType: 'default', scope: { pointers: { name: 0 }, stash: ['Paco'] }, + completed: false, + didStart: true, compilerOptions: { defaultRole: 'user' }, ast: expect.any(Object), globalConfig: undefined, diff --git a/src/compiler/chain.ts b/src/compiler/chain.ts index a716454..23ac57f 100644 --- a/src/compiler/chain.ts +++ b/src/compiler/chain.ts @@ -27,7 +27,7 @@ type ChainStep = ProviderConversation & { completed: boolean } -type StepResponse = +export type StepResponse = | string | M[] | (Omit & { @@ -70,6 +70,8 @@ export class Chain { serialized?: { ast: Fragment scope: Scope + didStart: boolean + completed: boolean globalConfig: Config | undefined globalMessages: Message[] } @@ -79,9 +81,10 @@ export class Chain { // Init from a serialized chain this.ast = serialized?.ast ?? parse(prompt) this.scope = serialized?.scope ?? new Scope(parameters) + this.didStart = serialized?.didStart ?? false + this._completed = serialized?.completed ?? false this.globalConfig = serialized?.globalConfig this.globalMessages = serialized?.globalMessages ?? [] - this.didStart = !!serialized this.adapter = adapter this.compileOptions = compileOptions @@ -161,15 +164,11 @@ export class Chain { } serialize() { - if (!this.didStart) { - throw new Error( - 'The chain has not started yet. You must call `step` at least once before calling `serialize`.', - ) - } - return { ast: this.ast, scope: this.scope.serialize(), + didStart: this.didStart, + completed: this._completed, adapterType: this.adapter.type, compilerOptions: this.compileOptions, globalConfig: this.globalConfig, diff --git a/src/compiler/deserializeChain.ts b/src/compiler/deserializeChain.ts index e2c2088..84acca4 100644 --- a/src/compiler/deserializeChain.ts +++ b/src/compiler/deserializeChain.ts @@ -1,16 +1,7 @@ import { SerializedChain } from '$promptl/compiler' import { Chain } from '$promptl/compiler/chain' import Scope from '$promptl/compiler/scope' -import { AdapterKey, Adapters, ProviderAdapter } from '$promptl/providers' -import { Message } from '$promptl/types' - -function getAdapter(adapterType: AdapterKey) { - const adapter = Adapters[adapterType] - - if (!adapter) throw new Error(`Adapter not found: ${adapterType}`) - - return adapter as ProviderAdapter -} +import { getAdapter } from '$promptl/providers' function safeSerializedData(data: string | SerializedChain): SerializedChain { try { @@ -29,6 +20,8 @@ function safeSerializedData(data: string | SerializedChain): SerializedChain { typeof serialized !== 'object' || typeof serialized.ast !== 'object' || typeof serialized.scope !== 'object' || + typeof serialized.didStart !== 'boolean' || + typeof serialized.completed !== 'boolean' || typeof serialized.adapterType !== 'string' || typeof serialized.rawText !== 'string' ) { @@ -38,6 +31,8 @@ function safeSerializedData(data: string | SerializedChain): SerializedChain { rawText: serialized.rawText, ast: serialized.ast, scope: serialized.scope, + didStart: serialized.didStart, + completed: serialized.completed, adapterType: serialized.adapterType, compilerOptions, globalConfig, @@ -60,6 +55,8 @@ export function deserializeChain({ rawText, ast, scope: serializedScope, + didStart, + completed, adapterType, compilerOptions, globalConfig, @@ -73,7 +70,14 @@ export function deserializeChain({ return new Chain({ prompt: rawText, - serialized: { ast, scope, globalConfig, globalMessages }, + serialized: { + ast, + scope, + didStart, + completed, + globalConfig, + globalMessages, + }, adapter, ...compilerOptions, }) diff --git a/src/compiler/scan.ts b/src/compiler/scan.ts index 945da0e..6ffbc3f 100644 --- a/src/compiler/scan.ts +++ b/src/compiler/scan.ts @@ -1,4 +1,4 @@ -import { createHash } from 'crypto' +import sha256 from 'fast-sha256' import { CUSTOM_MESSAGE_ROLE_ATTR, @@ -151,8 +151,12 @@ export class Scan { ) } - const contentToHash = [this.rawText, ...this.referencedHashes].join('') - const hash = createHash('sha256').update(contentToHash).digest('hex') + const content = new TextEncoder().encode( + [this.rawText, ...this.referencedHashes].join(''), + ) + const hash = Array.from(sha256(content)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') return { parameters: new Set([ diff --git a/src/index.rpc.ts b/src/index.rpc.ts new file mode 100644 index 0000000..880f00f --- /dev/null +++ b/src/index.rpc.ts @@ -0,0 +1,3 @@ +import { serve } from './rpc' + +serve() diff --git a/src/providers/index.ts b/src/providers/index.ts index e8311c5..41485eb 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,4 +1,5 @@ -import { defaultAdapter } from './adapter' +import { Message } from '$promptl/types' +import { defaultAdapter, ProviderAdapter } from './adapter' import { AnthropicAdapter } from './anthropic/adapter' import { OpenAIAdapter } from './openai/adapter' @@ -14,3 +15,9 @@ export type AdapterKey = keyof typeof Adapters export type AdapterMessageType< T extends keyof typeof Adapters = keyof typeof Adapters, > = ReturnType<(typeof Adapters)[T]['fromPromptl']>['messages'][number] + +export function getAdapter(adapterType: AdapterKey) { + const adapter = Adapters[adapterType] + if (!adapter) throw new Error(`Adapter not found: ${adapterType}`) + return adapter as ProviderAdapter +} diff --git a/src/rpc/index.ts b/src/rpc/index.ts new file mode 100644 index 0000000..f126cb8 --- /dev/null +++ b/src/rpc/index.ts @@ -0,0 +1 @@ +export { serve } from './server' diff --git a/src/rpc/procedures.ts b/src/rpc/procedures.ts new file mode 100644 index 0000000..2303e55 --- /dev/null +++ b/src/rpc/procedures.ts @@ -0,0 +1,63 @@ +import { scan, SerializedChain } from '../compiler' +import { Chain, StepResponse } from '../compiler/chain' +import { AdapterKey, getAdapter } from '../providers' +import { Message, MessageRole } from '../types' +import { RPC } from './types' + +export default { + [RPC.Procedure.ScanPrompt]: async ({ prompt }: { prompt: string }) => { + const result = await scan({ + prompt: prompt, + }) + + return { + hash: result.hash, + resolvedPrompt: result.resolvedPrompt, + config: result.config, + errors: result.errors, + parameters: Array.from(result.parameters), + isChain: result.isChain, + includedPromptPaths: Array.from(result.includedPromptPaths), + } + }, + + [RPC.Procedure.CreateChain]: async ({ + prompt, + parameters, + adapter, + defaultRole, + includeSourceMap, + }: { + prompt: string + parameters?: Record + adapter?: AdapterKey + defaultRole?: MessageRole + includeSourceMap?: boolean + }) => { + return new Chain({ + prompt: prompt, + parameters: parameters, + adapter: adapter ? getAdapter(adapter) : undefined, + defaultRole: defaultRole, + includeSourceMap: includeSourceMap, + }).serialize() + }, + + [RPC.Procedure.StepChain]: async ({ + chain: fromChain, + response, + }: { + chain: SerializedChain + response?: StepResponse + }) => { + const chain = Chain.deserialize({ serialized: fromChain })! + const result = await chain.step(response) + + return { + chain: chain.serialize(), + messages: result.messages, + config: result.config, + completed: result.completed, + } + }, +} as Record>> diff --git a/src/rpc/server.ts b/src/rpc/server.ts new file mode 100644 index 0000000..a4c0a05 --- /dev/null +++ b/src/rpc/server.ts @@ -0,0 +1,150 @@ +import procedures from './procedures' +import { RPC } from './types' + +const enum Fd { + StdIn = 0, + StdOut = 1, + StdErr = 2, +} + +const CHUNK_SIZE = 1024 + +function receive(): RPC.Call[] { + const chunks = [] + let size = 0 + + while (true) { + const chunk = new Uint8Array(CHUNK_SIZE) + + const bytes = Javy.IO.readSync(Fd.StdIn, chunk) + if (bytes === 0) { + break + } + + size += bytes + chunks.push(chunk.subarray(0, bytes)) + } + + const { calls } = chunks.reduce( + ({ offset, calls }, chunk) => { + calls.set(chunk, offset) + offset += chunk.length + + return { offset, calls } + }, + { offset: 0, calls: new Uint8Array(size) }, + ) + + return JSON.parse(new TextDecoder().decode(calls).trim()) +} + +function send(results: RPC.Result[]) { + const payload = new TextEncoder().encode(JSON.stringify(results) + '\n') + + Javy.IO.writeSync(Fd.StdOut, payload) +} + +async function execute(calls: RPC.Call[]): Promise[]> { + const results: RPC.Result[] = [] + + for (const call of calls) { + if (!call.id) { + results.push({ + id: -1, + error: { + code: RPC.ErrorCode.ExecuteError, + message: `Missing RPC call ID`, + }, + }) + continue + } + + if (call.id < 0) { + results.push({ + id: -1, + error: { + code: RPC.ErrorCode.ExecuteError, + message: `Invalid RPC call ID: ${call.id}`, + }, + }) + continue + } + + let result: RPC.Result = { + id: call.id, + } + + const handler = procedures[call.procedure] + if (!handler) { + result.error = { + code: RPC.ErrorCode.UnknownProcedure, + message: `Unknown RPC procedure: ${call.procedure}`, + } + results.push(result) + continue + } + + try { + result.value = await handler(call.parameters) + } catch (error) { + result.error = { + code: RPC.ErrorCode.ProcedureError, + message: error instanceof Error ? error.message : String(error), + details: error as any, + } + results.push(result) + continue + } + + results.push(result) + } + + return results +} + +export function serve() { + let calls: RPC.Call[] + + try { + calls = receive() + } catch (error) { + send([ + { + id: -1, + error: { + code: RPC.ErrorCode.ReceiveError, + message: `Failed to unmarshal RPC calls: ${error instanceof Error ? error.message : String(error)}`, + }, + }, + ]) + return + } + + execute(calls) + .then((results) => { + try { + send(results) + } catch (error) { + send([ + { + id: -1, + error: { + code: RPC.ErrorCode.SendError, + message: `Failed to marshal RPC results: ${error instanceof Error ? error.message : String(error)}`, + }, + }, + ]) + } + }) + .catch((error) => { + send([ + { + id: -1, + error: { + code: RPC.ErrorCode.ExecuteError, + message: `Failed to execute RPC calls: ${error instanceof Error ? error.message : String(error)}`, + }, + }, + ]) + }) +} diff --git a/src/rpc/types.ts b/src/rpc/types.ts new file mode 100644 index 0000000..5d41576 --- /dev/null +++ b/src/rpc/types.ts @@ -0,0 +1,46 @@ +declare global { + const Javy: { + IO: { + readSync: (fd: number, buffer: Uint8Array) => number + writeSync: (fd: number, buffer: Uint8Array) => void + } + } +} + +export namespace RPC { + export const enum Procedure { + ScanPrompt = 'scanPrompt', + CreateChain = 'createChain', + StepChain = 'stepChain', + } + + export type Call = { + id: number + procedure: Procedure + parameters: Parameters + } + + export const enum ErrorCode { + ReceiveError = 'RECEIVE_ERROR', + ExecuteError = 'EXECUTE_ERROR', + SendError = 'SEND_ERROR', + ProcedureError = 'PROCEDURE_ERROR', + UnknownProcedure = 'UNKNOWN_PROCEDURE', + UnknownError = 'UNKNOWN_ERROR', + } + + export type Error = { + code: ErrorCode + message: string + details?: Record + } + + export type Result = { + id: number + value?: Value + error?: Error + } + + // Errors are thrown in order to pass them + export type Handler = (parameters: Parameters) => Value +}