├── .github └── workflows │ ├── lint.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── eslint.config.mjs ├── examples ├── lib.ts └── rpc.ts ├── package.json ├── pnpm-lock.yaml ├── rollup.config.mjs ├── rollup.config.rpc.mjs ├── src ├── compiler │ ├── base │ │ ├── nodes │ │ │ ├── comment.ts │ │ │ ├── config.ts │ │ │ ├── for.test.ts │ │ │ ├── for.ts │ │ │ ├── fragment.ts │ │ │ ├── if.test.ts │ │ │ ├── if.ts │ │ │ ├── mustache.ts │ │ │ ├── tag.ts │ │ │ ├── tags │ │ │ │ ├── content.test.ts │ │ │ │ ├── content.ts │ │ │ │ ├── message.test.ts │ │ │ │ ├── message.ts │ │ │ │ ├── ref.test.ts │ │ │ │ ├── ref.ts │ │ │ │ ├── scope.test.ts │ │ │ │ ├── scope.ts │ │ │ │ ├── step.test.ts │ │ │ │ └── step.ts │ │ │ └── text.ts │ │ └── types.ts │ ├── chain-serialize.test.ts │ ├── chain.test.ts │ ├── chain.ts │ ├── compile.test.ts │ ├── compile.ts │ ├── deserializeChain.test.ts │ ├── deserializeChain.ts │ ├── errors.test.ts │ ├── index.ts │ ├── logic │ │ ├── index.ts │ │ ├── nodes │ │ │ ├── arrayExpression.ts │ │ │ ├── assignmentExpression.ts │ │ │ ├── binaryExpression.ts │ │ │ ├── callExpression.ts │ │ │ ├── chainExpression.ts │ │ │ ├── conditionalExpression.ts │ │ │ ├── identifier.ts │ │ │ ├── index.ts │ │ │ ├── literal.ts │ │ │ ├── memberExpression.ts │ │ │ ├── objectExpression.ts │ │ │ ├── sequenceExpression.ts │ │ │ ├── unaryExpression.ts │ │ │ └── updateExpression.ts │ │ ├── operators.ts │ │ └── types.ts │ ├── scan.test.ts │ ├── scan.ts │ ├── scope.ts │ ├── test │ │ └── helpers.ts │ ├── types.ts │ ├── utils.test.ts │ └── utils.ts ├── constants.ts ├── error │ ├── error.ts │ └── errors.ts ├── index.rpc.ts ├── index.ts ├── parser │ ├── index.ts │ ├── interfaces.ts │ ├── parser.test.ts │ ├── read │ │ ├── context.ts │ │ └── expression.ts │ ├── state │ │ ├── config.ts │ │ ├── fragment.ts │ │ ├── multi_line_comment.ts │ │ ├── mustache.test.ts │ │ ├── mustache.ts │ │ ├── tag.ts │ │ └── text.ts │ └── utils │ │ ├── acorn.ts │ │ ├── bracket.ts │ │ ├── entities.ts │ │ ├── full_char_code_at.ts │ │ ├── html.ts │ │ └── regex.ts ├── providers │ ├── adapter.ts │ ├── anthropic │ │ ├── adapter.test.ts │ │ ├── adapter.ts │ │ └── types.ts │ ├── index.ts │ ├── openai-responses │ │ ├── adapter.test.ts │ │ ├── adapter.ts │ │ ├── types.ts │ │ └── utils │ │ │ └── parsers │ │ │ ├── fileSearch.ts │ │ │ ├── functionCall.ts │ │ │ ├── inputMessage.ts │ │ │ ├── outputMessage.ts │ │ │ ├── parseSimpleContent.ts │ │ │ ├── reasoning.ts │ │ │ └── webSearch.ts │ ├── openai │ │ ├── adapter.test.ts │ │ ├── adapter.ts │ │ └── types.ts │ └── utils │ │ └── getMimeType.ts ├── rpc │ ├── index.ts │ ├── procedures.ts │ ├── server.ts │ └── types.ts ├── test │ └── helpers.ts ├── types │ ├── acorn.d.ts │ ├── customTypes.ts │ ├── index.ts │ └── message.ts └── utils │ └── names.ts ├── tsconfig.json └── vitest.config.ts /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linter & Types 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [20.x] 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 2 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Install pnpm 27 | uses: pnpm/action-setup@v3 28 | with: 29 | version: 9 30 | run_install: false 31 | 32 | - name: Get pnpm store directory 33 | shell: bash 34 | run: | 35 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 36 | 37 | - name: Setup pnpm cache 38 | uses: actions/cache@v3 39 | with: 40 | path: ${{ env.STORE_PATH }} 41 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 42 | restore-keys: | 43 | ${{ runner.os }}-pnpm-store- 44 | 45 | - name: Install Node.js dependencies 46 | run: pnpm install 47 | 48 | - name: Prettier 49 | run: pnpm prettier:check 50 | 51 | - name: Node.js Lint 52 | run: pnpm lint 53 | 54 | - name: TypeScript 55 | run: pnpm tc 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | name: Build and Publish 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '22' 21 | registry-url: 'https://registry.npmjs.org' 22 | 23 | - name: Install pnpm 24 | uses: pnpm/action-setup@v2 25 | with: 26 | version: 9 27 | 28 | - name: Get pnpm store directory 29 | shell: bash 30 | run: | 31 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 32 | 33 | - name: Setup pnpm cache 34 | uses: actions/cache@v3 35 | with: 36 | path: ${{ env.STORE_PATH }} 37 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 38 | restore-keys: | 39 | ${{ runner.os }}-pnpm-store- 40 | 41 | - name: Get package version 42 | id: get_version 43 | run: | 44 | CURRENT_VERSION=$(node -p "require('./package.json').version") 45 | echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT 46 | 47 | - name: Check version on npm 48 | id: check_version 49 | run: | 50 | NPM_VERSION=$(npm view promptl-ai version 2>/dev/null || echo "0.0.0") 51 | if [ "${{ steps.get_version.outputs.version }}" != "$NPM_VERSION" ]; then 52 | echo "should_publish=true" >> $GITHUB_OUTPUT 53 | else 54 | echo "should_publish=false" >> $GITHUB_OUTPUT 55 | fi 56 | 57 | - name: Install dependencies 58 | if: steps.check_version.outputs.should_publish == 'true' 59 | run: pnpm install 60 | 61 | - name: Build package (with workspace dependencies) 62 | if: steps.check_version.outputs.should_publish == 'true' 63 | run: pnpm run build 64 | 65 | - name: Run linter 66 | if: steps.check_version.outputs.should_publish == 'true' 67 | run: pnpm run lint 68 | 69 | - name: Run typescript checker 70 | if: steps.check_version.outputs.should_publish == 'true' 71 | run: pnpm run tc 72 | 73 | - name: Run tests 74 | if: steps.check_version.outputs.should_publish == 'true' 75 | run: pnpm run test 76 | 77 | - name: Publish to npm 78 | if: steps.check_version.outputs.should_publish == 'true' 79 | run: pnpm publish --access public --no-git-checks 80 | 81 | env: 82 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 83 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [20.x] 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 2 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Install pnpm 26 | uses: pnpm/action-setup@v3 27 | with: 28 | version: 9 29 | run_install: false 30 | 31 | - name: Get pnpm store directory 32 | shell: bash 33 | run: | 34 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 35 | 36 | - name: Setup pnpm cache 37 | uses: actions/cache@v3 38 | with: 39 | path: ${{ env.STORE_PATH }} 40 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pnpm-store- 43 | 44 | - name: Install Node.js dependencies 45 | run: pnpm install 46 | 47 | - name: Node.js Test 48 | env: 49 | NODE_ENV: test 50 | run: pnpm test 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development 12 | .env.development.local 13 | .env.test.local 14 | .env.production.local 15 | 16 | # Testing 17 | coverage 18 | 19 | # Turbo 20 | .turbo 21 | 22 | # Vercel 23 | .vercel 24 | 25 | # Build Outputs 26 | .next/ 27 | out/ 28 | build 29 | dist 30 | dist-rpc 31 | 32 | # Debug 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | 37 | # Misc 38 | .DS_Store 39 | 40 | # postgres data in development. Ignore 41 | docker/pgdata/ 42 | 43 | # Next.js 44 | next-env.d.ts 45 | apps/web/scripts-dist 46 | 47 | # File uploads in development 48 | tmp 49 | 50 | # Misc 51 | TODO.md 52 | .cursorrules 53 | .vscode/settings.json 54 | 55 | # Sentry 56 | .sentryclirc 57 | .env.sentry-build-plugin 58 | bin 59 | 60 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .* 2 | dist 3 | build 4 | **/tests/fixtures 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "jsxSingleQuote": true, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (C) 2024 Latitude Data SL 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PromptL 2 | 3 | ```sh 4 | npm install promptl-ai 5 | ``` 6 | 7 | ## What is PromptL? 8 | 9 | [PromptL](https://promptl.ai/) offers a common, easy-to-use syntax for defining dynamic prompts for LLMs. It is a simple, yet powerful language that allows you to define prompts in a human-readable format, while still being able to leverage the full power of LLMs. 10 | 11 | ## PromptL in other languages 12 | 13 | Thanks to our [universal WASM module with RPC](examples/rpc.ts), you can use PromptL in any language that can run WASM natively or through a library. These are the official bindings: 14 | 15 | - [Python](https://github.com/latitude-dev/promptl-py) 16 | 17 | ## Why PromptL? 18 | 19 | While LLMs are becoming more powerful and popular by the day, defining prompts for them can be a daunting task. All main LLM providers, despite their differences, have adopted a similar structure for their prompting. It consists of a conversation between the user and assistant, which is defined by a list of messages and a series of configuration options. In response, it will return an assistant message as a reply. 20 | 21 | This structure looks something like this: 22 | 23 | ```json 24 | { 25 | "model": "", 26 | "temperature": 0.6, 27 | "messages": [ 28 | { 29 | "type": "system", 30 | "content": "You are a useful AI assistant expert in geography." 31 | }, 32 | { 33 | "type": "user", 34 | "content": "Hi! What's the capital of Spain?" 35 | } 36 | ] 37 | } 38 | ``` 39 | 40 | This structure may be simple, but it can be tough for non-techy users to grasp or write it from scratch. In addition to this, creating a single static prompt is not that useful. Typically, users need to define conversations dynamically, where the flow changes based on user input or event parameters. The problem is, adding code to modify the conversation based on these parameters can get confusing and repetitive – it needs to be done for each prompt individually. 41 | 42 | This is how the PromptL syntax steps in. It defines a language simple enough for any user to use and understand. And, at the same time, it offers immense power for users who want to maximize its potential. It allows users to define the same structure they would build before, but in a more readable way. Plus, they can add custom dynamic logic to create anything they need, all in just a single file. 43 | 44 | Take a look at the same prompt as before, using the PromptL syntax: 45 | 46 | ```plaintext 47 | --- 48 | model: 49 | temperature: 0.6 50 | --- 51 | 52 | You are a useful AI assistant expert in geography. 53 | 54 | 55 | Hi! What's the capital of {{ country_name }}? 56 | 57 | ``` 58 | 59 | In this case, not only the syntax is way more readable and maintainable, but it also allows for dynamic generation of prompts by using variables like `{{ country_name }}`. 60 | 61 | This is just a small example of what PromptL can do. It is a powerful tool that can help you define dynamic prompts for your LLMs in a simple and easy way, without giving up any feature or functionality from the original structure. 62 | 63 | ## Links 64 | 65 | [Website](https://promptl.ai/) | [Documentation](https://docs.latitude.so/promptl/getting-started/introduction) 66 | 67 | ## Development 68 | 69 | To build the JavaScript library, run `pnpm build:lib`. 70 | 71 | To build the universal WASM module with RPC, first install [`javy`](https://github.com/bytecodealliance/javy/releases) and then run `pnpm build:rpc`. 72 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin' 2 | import prettier from 'eslint-plugin-prettier' 3 | import globals from 'globals' 4 | import tsParser from '@typescript-eslint/parser' 5 | import path from 'node:path' 6 | import { fileURLToPath } from 'node:url' 7 | import js from '@eslint/js' 8 | import { FlatCompat } from '@eslint/eslintrc' 9 | 10 | const __filename = fileURLToPath(import.meta.url) 11 | const __dirname = path.dirname(__filename) 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all, 16 | }) 17 | 18 | export default [ 19 | { 20 | ignores: ['**/.*.js', '**/node_modules/', '**/dist/'], 21 | }, 22 | ...compat.extends('eslint:recommended'), 23 | { 24 | plugins: { 25 | '@typescript-eslint': typescriptEslintEslintPlugin, 26 | 'prettier': prettier, 27 | }, 28 | 29 | languageOptions: { 30 | globals: { 31 | ...globals.node, 32 | ...globals.browser, 33 | Javy: 'readonly', 34 | }, 35 | 36 | parser: tsParser, 37 | }, 38 | 39 | settings: { 40 | 'import/resolver': { 41 | typescript: { 42 | project: './tsconfig.json', 43 | }, 44 | }, 45 | }, 46 | 47 | rules: { 48 | 'no-constant-condition': 'off', 49 | 'no-unused-vars': 'off', 50 | 51 | '@typescript-eslint/no-unused-vars': [ 52 | 'error', 53 | { 54 | args: 'all', 55 | argsIgnorePattern: '^_', 56 | varsIgnorePattern: '^_', 57 | }, 58 | ], 59 | }, 60 | }, 61 | { 62 | files: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'], 63 | }, 64 | ] 65 | -------------------------------------------------------------------------------- /examples/lib.ts: -------------------------------------------------------------------------------- 1 | // Run `pnpm build:lib` before running this example 2 | 3 | import assert from 'node:assert' 4 | import { inspect } from 'node:util' 5 | import { Chain } from '../dist/index.js' 6 | 7 | const prompt = ` 8 | 9 | 10 | You are a helpful assistant. 11 | 12 | 13 | Say hello. 14 | 15 | 16 | 17 | 18 | Now say goodbye. 19 | 20 | 21 | ` 22 | 23 | const chain = new Chain({ prompt }) 24 | let conversation = await chain.step() 25 | conversation = await chain.step('Hello!') 26 | conversation = await chain.step('Goodbye!') 27 | 28 | assert(chain.completed) 29 | assert(conversation.completed) 30 | 31 | console.log(inspect(conversation.messages, { depth: null })) 32 | -------------------------------------------------------------------------------- /examples/rpc.ts: -------------------------------------------------------------------------------- 1 | // Run `pnpm build:rpc` before running this example 2 | 3 | import assert from 'node:assert' 4 | import { type FileHandle, mkdir, open, readFile, writeFile } from 'node:fs/promises' 5 | import { tmpdir } from 'node:os' 6 | import { join } from 'node:path' 7 | import { inspect } from 'node:util' 8 | import { WASI } from 'node:wasi' 9 | 10 | const PROMPTL_WASM_PATH = './dist/promptl.wasm' 11 | 12 | const prompt = ` 13 | 14 | 15 | You are a helpful assistant. 16 | 17 | 18 | Say hello. 19 | 20 | 21 | 22 | 23 | Now say goodbye. 24 | 25 | 26 | ` 27 | let chain, conversation 28 | chain = await createChain(prompt); 29 | ({ chain, ...conversation } = await stepChain(chain)); 30 | ({ chain, ...conversation } = await stepChain(chain, 'Hello!')); 31 | ({ chain, ...conversation } = await stepChain(chain, 'Goodbye!')); 32 | 33 | assert(chain.completed) 34 | assert(conversation.completed) 35 | 36 | console.log(inspect(conversation.messages, { depth: null })) 37 | 38 | // Utility functions 39 | 40 | async function createChain(prompt: string): Promise { 41 | return await execute([ 42 | { 43 | procedure: 'createChain', 44 | parameters: { 45 | prompt: prompt, 46 | }, 47 | }, 48 | ]).then((result) => { 49 | if (result[0]!.error) { 50 | console.log('Error dump: ', inspect(result[0]!.error, { depth: null })) 51 | throw new Error(result[0]!.error.message) 52 | } 53 | return result[0]!.value 54 | }) 55 | } 56 | 57 | async function stepChain(chain: any, response?: any): Promise { 58 | return await execute([ 59 | { 60 | procedure: 'stepChain', 61 | parameters: { 62 | chain: chain, 63 | response: response, 64 | }, 65 | }, 66 | ]).then((result) => { 67 | if (result[0]!.error) { 68 | console.log('Error dump: ', inspect(result[0]!.error, { depth: null })) 69 | throw new Error(result[0]!.error.message) 70 | } 71 | return result[0]!.value 72 | }) 73 | } 74 | 75 | async function execute(data: any): Promise { 76 | const dir = join(tmpdir(), 'promptl') 77 | const stdin_path = join(dir, 'stdin') 78 | const stdout_path = join(dir, 'stdout') 79 | const stderr_path = join(dir, 'stderr') 80 | 81 | await mkdir(dir, { recursive: true }) 82 | await writeFile(stdin_path, '') 83 | await writeFile(stdout_path, '') 84 | await writeFile(stderr_path, '') 85 | 86 | let stdin: FileHandle | undefined 87 | let stdout: FileHandle | undefined 88 | let stderr: FileHandle | undefined 89 | 90 | let wasmStdin: FileHandle | undefined 91 | let wasmStdout: FileHandle | undefined 92 | let wasmStderr: FileHandle | undefined 93 | 94 | try { 95 | stdin = await open(stdin_path, 'w') 96 | stdout = await open(stdout_path, 'r') 97 | stderr = await open(stderr_path, 'r') 98 | 99 | wasmStdin = await open(stdin_path, 'r') 100 | wasmStdout = await open(stdout_path, 'w') 101 | wasmStderr = await open(stderr_path, 'w') 102 | 103 | const wasi = new WASI({ 104 | version: 'preview1', 105 | args: [], 106 | env: {}, 107 | stdin: wasmStdin.fd, 108 | stdout: wasmStdout.fd, 109 | stderr: wasmStderr.fd, 110 | returnOnExit: true, 111 | }) 112 | 113 | const bytes = await readFile(PROMPTL_WASM_PATH) 114 | WebAssembly.validate(bytes) 115 | const module = await WebAssembly.compile(bytes) 116 | const instance = await WebAssembly.instantiate( 117 | module, 118 | wasi.getImportObject(), 119 | ) 120 | 121 | await send(stdin, data) 122 | 123 | wasi.start(instance) 124 | 125 | const [out, err] = await Promise.all([receive(stdout), receive(stderr)]) 126 | if (err) throw new Error(err) 127 | 128 | return out 129 | } finally { 130 | await Promise.all([ 131 | stdin?.close(), 132 | stdout?.close(), 133 | stderr?.close(), 134 | wasmStdin?.close(), 135 | wasmStdout?.close(), 136 | wasmStderr?.close(), 137 | ]) 138 | } 139 | } 140 | 141 | async function send(file: FileHandle, data: any) { 142 | await writeFile(file, JSON.stringify(data) + '\n', { 143 | encoding: 'utf8', 144 | flush: true, 145 | }) 146 | } 147 | 148 | async function receive(file: FileHandle): Promise { 149 | const data = await readFile(file, { 150 | encoding: 'utf8', 151 | }).then((data) => data.trim()) 152 | 153 | try { 154 | return JSON.parse(data) 155 | } catch { 156 | return data 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "promptl-ai", 3 | "version": "0.6.6", 4 | "author": "Latitude Data", 5 | "license": "MIT", 6 | "description": "Compiler for PromptL, the prompt language", 7 | "type": "module", 8 | "main": "./dist/index.cjs", 9 | "module": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "exports": { 12 | ".": { 13 | "import": { 14 | "types": "./dist/index.d.ts", 15 | "default": "./dist/index.js" 16 | }, 17 | "require": { 18 | "types": "./dist/index.d.ts", 19 | "default": "./dist/index.cjs" 20 | } 21 | } 22 | }, 23 | "files": [ 24 | "dist" 25 | ], 26 | "scripts": { 27 | "dev": "rollup -c -w", 28 | "build": "rollup -c", 29 | "build:rpc": "rollup -c rollup.config.rpc.mjs", 30 | "test": "vitest run", 31 | "test:watch": "vitest", 32 | "prettier": "prettier --write src/**/*.ts", 33 | "prettier:check": "prettier --check src/**/*.ts --ignore-path .prettierrcignore", 34 | "lint": "eslint src", 35 | "tc": "tsc --noEmit" 36 | }, 37 | "dependencies": { 38 | "acorn": "^8.9.0", 39 | "code-red": "^1.0.3", 40 | "fast-sha256": "^1.3.0", 41 | "locate-character": "^3.0.0", 42 | "yaml": "^2.4.5", 43 | "openai": "^4.98.0", 44 | "zod": "^3.23.8" 45 | }, 46 | "devDependencies": { 47 | "@eslint/eslintrc": "^3.2.0", 48 | "@eslint/js": "^9.17.0", 49 | "@rollup/plugin-alias": "^5.1.0", 50 | "@rollup/plugin-commonjs": "^25.0.7", 51 | "@rollup/plugin-node-resolve": "^15.2.3", 52 | "@rollup/plugin-typescript": "^11.1.6", 53 | "@types/estree": "^1.0.1", 54 | "@types/node": "^20.12.12", 55 | "@typescript-eslint/eslint-plugin": "^8.19.0", 56 | "eslint": "^9.17.0", 57 | "eslint-plugin-prettier": "^5.2.1", 58 | "globals": "^15.14.0", 59 | "prettier": "^3.4.2", 60 | "rollup": "^4.10.0", 61 | "rollup-plugin-dts": "^6.1.1", 62 | "rollup-plugin-execute": "^1.1.1", 63 | "tslib": "^2.8.1", 64 | "tsx": "^4.19.2", 65 | "typescript": "^5.6.3", 66 | "vitest": "^1.2.2" 67 | }, 68 | "packageManager": "pnpm@9.8.0" 69 | } 70 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | import * as url from 'url' 3 | 4 | import alias from '@rollup/plugin-alias' 5 | import typescript from '@rollup/plugin-typescript' 6 | import { dts } from 'rollup-plugin-dts' 7 | 8 | /** 9 | * We have a internal circular dependency in the compiler, 10 | * which is intentional. We think in this case Rollup is too noisy. 11 | * 12 | * @param {import('rollup').RollupLog} warning 13 | * @returns {boolean} 14 | */ 15 | function isInternalCircularDependency(warning) { 16 | return ( 17 | warning.code == 'CIRCULAR_DEPENDENCY' && 18 | warning.message.includes('src/compiler') && 19 | !warning.message.includes('node_modules') 20 | ) 21 | } 22 | 23 | const __dirname = path.dirname(url.fileURLToPath(import.meta.url)) 24 | const aliasEntries = { 25 | entries: [{ find: '$promptl', replacement: path.resolve(__dirname, 'src') }], 26 | } 27 | 28 | /** @type {import('rollup').RollupOptions} */ 29 | export default [ 30 | { 31 | onwarn: (warning, warn) => { 32 | if (isInternalCircularDependency(warning)) return 33 | 34 | warn(warning) 35 | }, 36 | input: 'src/index.ts', 37 | output: [ 38 | { file: 'dist/index.js' }, 39 | { file: 'dist/index.cjs', format: 'cjs' }, 40 | ], 41 | plugins: [ 42 | typescript({ 43 | noEmit: true, 44 | tsconfig: './tsconfig.json', 45 | exclude: ['**/__tests__', '**/*.test.ts'], 46 | }), 47 | ], 48 | external: [ 49 | 'openai', 50 | 'acorn', 51 | 'locate-character', 52 | 'code-red', 53 | 'node:crypto', 54 | 'yaml', 55 | 'crypto', 56 | 'zod', 57 | 'fast-sha256', 58 | ], 59 | }, 60 | { 61 | input: 'src/index.ts', 62 | output: [{ file: 'dist/index.d.ts', format: 'es' }], 63 | plugins: [ 64 | alias(aliasEntries), 65 | dts({ 66 | tsconfig: './tsconfig.json', 67 | exclude: ['**/__tests__', '**/*.test.ts'], 68 | }), 69 | ], 70 | }, 71 | ] 72 | -------------------------------------------------------------------------------- /rollup.config.rpc.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs' 2 | import nodeResolve from '@rollup/plugin-node-resolve' 3 | import typescript from '@rollup/plugin-typescript' 4 | import execute from 'rollup-plugin-execute' 5 | 6 | /** 7 | * We have a internal circular dependency in the compiler, 8 | * which is intentional. We think in this case Rollup is too noisy. 9 | * 10 | * @param {import('rollup').RollupLog} warning 11 | * @returns {boolean} 12 | */ 13 | function isInternalCircularDependency(warning) { 14 | return ( 15 | warning.code == 'CIRCULAR_DEPENDENCY' && 16 | warning.message.includes('src/compiler') && 17 | !warning.message.includes('node_modules') 18 | ) 19 | } 20 | 21 | /** @type {import('rollup').RollupOptions} */ 22 | export default { 23 | onwarn: (warning, warn) => { 24 | if (!isInternalCircularDependency(warning)) warn(warning) 25 | }, 26 | input: 'src/index.rpc.ts', 27 | output: [ 28 | { 29 | file: 'dist-rpc/promptl.js', 30 | format: 'es', 31 | }, 32 | ], 33 | plugins: [ 34 | nodeResolve({ 35 | preferBuiltins: true, 36 | }), 37 | commonjs(), 38 | typescript({ 39 | noEmit: true, 40 | tsconfig: './tsconfig.json', 41 | exclude: ['**/__tests__', '**/*.test.ts'], 42 | }), 43 | execute([ 44 | [ 45 | 'javy build', 46 | '-C dynamic=n', 47 | '-C source-compression=y', 48 | '-J javy-stream-io=y', 49 | '-J simd-json-builtins=y', 50 | '-J text-encoding=y', 51 | '-J event-loop=y', 52 | '-o dist-rpc/promptl.wasm', 53 | 'dist-rpc/promptl.js', 54 | ].join(' '), 55 | ]), 56 | ], 57 | } 58 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/comment.ts: -------------------------------------------------------------------------------- 1 | import { Comment } from '$promptl/parser/interfaces' 2 | 3 | import { CompileNodeContext } from '../types' 4 | 5 | export async function compile(_: CompileNodeContext) { 6 | /* do nothing */ 7 | } 8 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/config.ts: -------------------------------------------------------------------------------- 1 | import { Config as ConfigNode } from '$promptl/parser/interfaces' 2 | import yaml from 'yaml' 3 | 4 | import { CompileNodeContext } from '../types' 5 | 6 | export async function compile({ 7 | node, 8 | setConfig, 9 | }: CompileNodeContext): Promise { 10 | setConfig(yaml.parse(node.value)) 11 | } 12 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/for.test.ts: -------------------------------------------------------------------------------- 1 | import CompileError from '$promptl/error/error' 2 | import { getExpectedError } from '$promptl/test/helpers' 3 | import { Message, MessageContent, TextContent } from '$promptl/types' 4 | import { describe, expect, it } from 'vitest' 5 | 6 | import { render } from '../../index' 7 | import { removeCommonIndent } from '../../utils' 8 | 9 | async function getCompiledText( 10 | prompt: string, 11 | parameters: Record = {}, 12 | ) { 13 | const result = await render({ 14 | prompt: removeCommonIndent(prompt), 15 | parameters, 16 | }) 17 | 18 | return result.messages.reduce((acc: string, message: Message) => { 19 | const content = 20 | typeof message.content === 'string' 21 | ? message.content 22 | : (message.content as MessageContent[]) 23 | .map((c) => (c as TextContent).text) 24 | .join('') 25 | 26 | return acc + content 27 | }, '') 28 | } 29 | 30 | describe('each loops', async () => { 31 | it('iterates over any iterable object', async () => { 32 | const prompt1 = `{{ for element in [1, 2, 3] }} {{element}} {{ endfor }}` 33 | const prompt2 = `{{ for element in "foo" }} {{element}} {{ endfor }}` 34 | 35 | const result1 = await getCompiledText(prompt1) 36 | const result2 = await getCompiledText(prompt2) 37 | 38 | expect(result1).toBe('123') 39 | expect(result2).toBe('foo') 40 | }) 41 | 42 | it('computes the else block when the element is not iterable', async () => { 43 | const prompt1 = `{{ for element in 5}} {{element}} {{ else }} FOO {{ endfor }}` 44 | const prompt2 = `{{ for element in { a: 1, b: 2, c: 3 } }} {{element}} {{ else }} FOO {{ endfor }}` 45 | 46 | const result1 = await getCompiledText(prompt1) 47 | const result2 = await getCompiledText(prompt2) 48 | 49 | expect(result1).toBe('FOO') 50 | expect(result2).toBe('FOO') 51 | }) 52 | 53 | it('computes the else block when the iterable object is empty', async () => { 54 | const prompt = `{{ for element in [] }} {{element}} {{ else }} FOO {{ endfor }}` 55 | const result = await getCompiledText(prompt) 56 | expect(result).toBe('FOO') 57 | }) 58 | 59 | it('does not do anything when the iterable object is not iterable and there is no else block', async () => { 60 | const prompt = `{{ for element in 5 }} {{element}} {{ endfor }}` 61 | expect(render({ prompt, parameters: {} })).resolves 62 | }) 63 | 64 | it('gives access to the index of the element', async () => { 65 | const prompt = `{{ for element, index in ['a', 'b', 'c'] }} {{index}} {{ endfor }}` 66 | const result = await getCompiledText(prompt) 67 | expect(result).toBe('012') 68 | }) 69 | 70 | it('respects variable scope', async () => { 71 | const prompt1 = `{{ for elemenet in ['a', 'b', 'c'] }} {{foo = 5}} {{ endfor }} {{foo}}` 72 | const prompt2 = `{{foo = 5}} {{ for element in ['a', 'b', 'c'] }} {{foo = 7}} {{ endfor }} {{foo}}` 73 | const prompt3 = `{{foo = 5}} {{ for element in [1, 2, 3] }} {{foo += element}} {{ endfor }} {{foo}}` 74 | const action1 = () => render({ prompt: prompt1, parameters: {} }) 75 | const error1 = await getExpectedError(action1, CompileError) 76 | const result2 = await getCompiledText(prompt2) 77 | const result3 = await getCompiledText(prompt3) 78 | 79 | expect(error1.code).toBe('variable-not-declared') 80 | expect(result2).toBe('7') 81 | expect(result3).toBe('11') 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/for.ts: -------------------------------------------------------------------------------- 1 | import { hasContent, isIterable } from '$promptl/compiler/utils' 2 | import errors from '$promptl/error/errors' 3 | import { ForBlock } from '$promptl/parser/interfaces' 4 | 5 | import { CompileNodeContext, TemplateNodeWithStatus } from '../types' 6 | 7 | type ForNodeWithStatus = TemplateNodeWithStatus & { 8 | status: TemplateNodeWithStatus['status'] & { 9 | loopIterationIndex: number 10 | } 11 | } 12 | 13 | export async function compile({ 14 | node, 15 | scope, 16 | isInsideStepTag, 17 | isInsideContentTag, 18 | isInsideMessageTag, 19 | resolveBaseNode, 20 | resolveExpression, 21 | expressionError, 22 | fullPath, 23 | }: CompileNodeContext) { 24 | const nodeWithStatus = node as ForNodeWithStatus 25 | nodeWithStatus.status = { 26 | ...nodeWithStatus.status, 27 | scopePointers: scope.getPointers(), 28 | } 29 | 30 | const iterableElement = await resolveExpression(node.expression, scope) 31 | if (!isIterable(iterableElement) || !(await hasContent(iterableElement))) { 32 | const childScope = scope.copy() 33 | for await (const childNode of node.else?.children ?? []) { 34 | await resolveBaseNode({ 35 | node: childNode, 36 | scope: childScope, 37 | isInsideStepTag, 38 | isInsideMessageTag, 39 | isInsideContentTag, 40 | fullPath, 41 | }) 42 | } 43 | return 44 | } 45 | 46 | const contextVarName = node.context.name 47 | const indexVarName = node.index?.name 48 | if (scope.exists(contextVarName)) { 49 | throw expressionError( 50 | errors.variableAlreadyDeclared(contextVarName), 51 | node.context, 52 | ) 53 | } 54 | 55 | if (indexVarName && scope.exists(indexVarName)) { 56 | throw expressionError( 57 | errors.variableAlreadyDeclared(indexVarName), 58 | node.index!, 59 | ) 60 | } 61 | 62 | let i = 0 63 | 64 | for await (const element of iterableElement) { 65 | if (i < (nodeWithStatus.status.loopIterationIndex ?? 0)) { 66 | i++ 67 | continue 68 | } 69 | nodeWithStatus.status.loopIterationIndex = i 70 | 71 | const localScope = scope.copy() 72 | localScope.set(contextVarName, element) 73 | if (indexVarName) localScope.set(indexVarName, i) 74 | 75 | for await (const childNode of node.children ?? []) { 76 | await resolveBaseNode({ 77 | node: childNode, 78 | scope: localScope, 79 | isInsideStepTag, 80 | isInsideMessageTag, 81 | isInsideContentTag, 82 | fullPath, 83 | completedValue: `step_${i}`, 84 | }) 85 | } 86 | 87 | i++ 88 | } 89 | 90 | nodeWithStatus.status = { 91 | ...nodeWithStatus.status, 92 | loopIterationIndex: 0, 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/fragment.ts: -------------------------------------------------------------------------------- 1 | import { Fragment } from '$promptl/parser/interfaces' 2 | 3 | import { CompileNodeContext } from '../types' 4 | 5 | export async function compile({ 6 | node, 7 | scope, 8 | isInsideStepTag, 9 | isInsideContentTag, 10 | isInsideMessageTag, 11 | fullPath, 12 | resolveBaseNode, 13 | }: CompileNodeContext) { 14 | for await (const childNode of node.children ?? []) { 15 | await resolveBaseNode({ 16 | node: childNode, 17 | scope, 18 | isInsideStepTag, 19 | isInsideMessageTag, 20 | isInsideContentTag, 21 | fullPath, 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/if.test.ts: -------------------------------------------------------------------------------- 1 | import { AssistantMessage, MessageRole, UserMessage } from '$promptl/types' 2 | import { describe, expect, it, vi } from 'vitest' 3 | 4 | import { render } from '../..' 5 | import { removeCommonIndent } from '../../utils' 6 | 7 | describe('conditional expressions', async () => { 8 | it('only evaluates the content inside the correct branch', async () => { 9 | const prompt = ` 10 | {{ if foo }} 11 | {{ whenTrue() }} 12 | {{ else }} 13 | {{ whenFalse() }} 14 | {{ endif }} 15 | ` 16 | const whenTrue = vi.fn() 17 | const whenFalse = vi.fn() 18 | 19 | await render({ 20 | prompt: removeCommonIndent(prompt), 21 | parameters: { 22 | foo: true, 23 | whenTrue, 24 | whenFalse, 25 | }, 26 | }) 27 | 28 | expect(whenTrue).toHaveBeenCalled() 29 | expect(whenFalse).not.toHaveBeenCalled() 30 | 31 | whenTrue.mockClear() 32 | whenFalse.mockClear() 33 | 34 | await render({ 35 | prompt: removeCommonIndent(prompt), 36 | parameters: { 37 | foo: false, 38 | whenTrue, 39 | whenFalse, 40 | }, 41 | }) 42 | 43 | expect(whenTrue).not.toHaveBeenCalled() 44 | expect(whenFalse).toHaveBeenCalled() 45 | }) 46 | 47 | it('adds messages conditionally', async () => { 48 | const prompt = ` 49 | {{ if foo }} 50 | Foo! 51 | {{ else }} 52 | Bar! 53 | {{ endif }} 54 | ` 55 | const result1 = await render({ 56 | prompt: removeCommonIndent(prompt), 57 | parameters: { 58 | foo: true, 59 | }, 60 | }) 61 | const result2 = await render({ 62 | prompt: removeCommonIndent(prompt), 63 | parameters: { 64 | foo: false, 65 | }, 66 | }) 67 | 68 | expect(result1.messages.length).toBe(1) 69 | const message1 = result1.messages[0]! as UserMessage 70 | expect(message1.role).toBe('user') 71 | expect(message1.content.length).toBe(1) 72 | expect(message1.content[0]!.type).toBe('text') 73 | expect(message1.content).toEqual([{ type: 'text', text: 'Foo!' }]) 74 | 75 | expect(result2.messages.length).toBe(1) 76 | const message2 = result2.messages[0]! as AssistantMessage 77 | expect(message2.role).toBe('assistant') 78 | expect(message2.content).toEqual([{ type: 'text', text: 'Bar!' }]) 79 | }) 80 | 81 | it('adds message contents conditionally', async () => { 82 | const prompt = ` 83 | 84 | {{ if foo }} 85 | Foo! 86 | {{ else }} 87 | Bar! 88 | {{ endif }} 89 | 90 | ` 91 | 92 | const result1 = await render({ 93 | prompt: removeCommonIndent(prompt), 94 | parameters: { foo: true }, 95 | }) 96 | const result2 = await render({ 97 | prompt: removeCommonIndent(prompt), 98 | parameters: { foo: false }, 99 | }) 100 | 101 | expect(result1.messages).toEqual([ 102 | { 103 | role: MessageRole.user, 104 | name: undefined, 105 | content: [ 106 | { 107 | type: 'text', 108 | text: 'Foo!', 109 | }, 110 | ], 111 | }, 112 | ]) 113 | expect(result2.messages).toEqual([ 114 | { 115 | role: MessageRole.user, 116 | name: undefined, 117 | content: [ 118 | { 119 | type: 'text', 120 | text: 'Bar!', 121 | }, 122 | ], 123 | }, 124 | ]) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/if.ts: -------------------------------------------------------------------------------- 1 | import { IfBlock } from '$promptl/parser/interfaces' 2 | 3 | import { CompileNodeContext } from '../types' 4 | 5 | export async function compile({ 6 | node, 7 | scope, 8 | isInsideStepTag, 9 | isInsideContentTag, 10 | isInsideMessageTag, 11 | fullPath, 12 | resolveBaseNode, 13 | resolveExpression, 14 | }: CompileNodeContext) { 15 | const condition = await resolveExpression(node.expression, scope) 16 | const children = (condition ? node.children : node.else?.children) ?? [] 17 | const childScope = scope.copy() 18 | for await (const childNode of children ?? []) { 19 | await resolveBaseNode({ 20 | node: childNode, 21 | scope: childScope, 22 | isInsideStepTag, 23 | isInsideMessageTag, 24 | isInsideContentTag, 25 | fullPath, 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/mustache.ts: -------------------------------------------------------------------------------- 1 | import errors from '$promptl/error/errors' 2 | import { MustacheTag } from '$promptl/parser/interfaces' 3 | import { 4 | isPromptLFile, 5 | PromptLFile, 6 | promptLFileToMessageContent, 7 | } from '$promptl/types' 8 | 9 | import { CompileNodeContext } from '../types' 10 | 11 | export async function compile({ 12 | node, 13 | scope, 14 | addStrayText, 15 | groupStrayText, 16 | isInsideContentTag, 17 | addContent, 18 | resolveExpression, 19 | baseNodeError, 20 | }: CompileNodeContext) { 21 | const expression = node.expression 22 | const value = await resolveExpression(expression, scope) 23 | if (value === undefined) return 24 | 25 | const files = promptLFileArray(value) 26 | if (files) { 27 | if (isInsideContentTag) { 28 | if (files.length > 1) { 29 | baseNodeError(errors.multipleFilesInContentTag, node) 30 | return 31 | } 32 | 33 | const file = files[0]! 34 | addStrayText(String(file.url), node) 35 | return 36 | } 37 | 38 | groupStrayText() 39 | 40 | files.forEach((file) => { 41 | addContent({ 42 | node, 43 | content: promptLFileToMessageContent(file), 44 | }) 45 | }) 46 | 47 | return 48 | } 49 | 50 | if (typeof value === 'object' && value !== null) { 51 | addStrayText(JSON.stringify(value), node) 52 | return 53 | } 54 | 55 | addStrayText(String(value), node) 56 | } 57 | 58 | function promptLFileArray(value: unknown): PromptLFile[] | undefined { 59 | if (isPromptLFile(value)) return [value] 60 | if (Array.isArray(value) && value.length && value.every(isPromptLFile)) { 61 | return value 62 | } 63 | return undefined 64 | } 65 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/tag.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isChainStepTag, 3 | isContentTag, 4 | isMessageTag, 5 | isRefTag, 6 | isScopeTag, 7 | } from '$promptl/compiler/utils' 8 | import errors from '$promptl/error/errors' 9 | import { 10 | ChainStepTag, 11 | ContentTag, 12 | ElementTag, 13 | MessageTag, 14 | ReferenceTag, 15 | ScopeTag, 16 | } from '$promptl/parser/interfaces' 17 | 18 | import { CompileNodeContext } from '../types' 19 | import { compile as resolveContent } from './tags/content' 20 | import { compile as resolveMessage } from './tags/message' 21 | import { compile as resolveRef } from './tags/ref' 22 | import { compile as resolveChainStep } from './tags/step' 23 | import { compile as resolveScope } from './tags/scope' 24 | 25 | async function resolveTagAttributes({ 26 | node: tagNode, 27 | scope, 28 | resolveExpression, 29 | }: CompileNodeContext): Promise> { 30 | const attributeNodes = tagNode.attributes 31 | if (attributeNodes.length === 0) return {} 32 | 33 | const attributes: Record = {} 34 | for (const attributeNode of attributeNodes) { 35 | const { name, value } = attributeNode 36 | if (value === true) { 37 | attributes[name] = true 38 | continue 39 | } 40 | 41 | const accumulatedValue: unknown[] = [] 42 | for await (const node of value) { 43 | if (node.type === 'Text') { 44 | if (node.data) { 45 | accumulatedValue.push(node.data) 46 | } 47 | continue 48 | } 49 | 50 | if (node.type === 'MustacheTag') { 51 | const expression = node.expression 52 | const resolvedValue = await resolveExpression(expression, scope) 53 | if (resolvedValue === undefined) continue 54 | accumulatedValue.push(resolvedValue) 55 | continue 56 | } 57 | } 58 | 59 | const finalValue = 60 | accumulatedValue.length > 1 61 | ? accumulatedValue.map(String).join('') 62 | : accumulatedValue[0] 63 | 64 | attributes[name] = finalValue 65 | } 66 | 67 | return attributes 68 | } 69 | 70 | export async function compile(props: CompileNodeContext) { 71 | const { 72 | node, 73 | scope, 74 | isInsideStepTag, 75 | isInsideContentTag, 76 | isInsideMessageTag, 77 | fullPath, 78 | resolveBaseNode, 79 | baseNodeError, 80 | groupStrayText, 81 | } = props 82 | groupStrayText() 83 | 84 | const attributes = await resolveTagAttributes(props) 85 | 86 | if (isContentTag(node)) { 87 | await resolveContent(props as CompileNodeContext, attributes) 88 | return 89 | } 90 | 91 | if (isMessageTag(node)) { 92 | await resolveMessage(props as CompileNodeContext, attributes) 93 | return 94 | } 95 | 96 | if (isRefTag(node)) { 97 | await resolveRef(props as CompileNodeContext, attributes) 98 | return 99 | } 100 | 101 | if (isScopeTag(node)) { 102 | await resolveScope(props as CompileNodeContext, attributes) 103 | return 104 | } 105 | 106 | if (isChainStepTag(node)) { 107 | await resolveChainStep( 108 | props as CompileNodeContext, 109 | attributes, 110 | ) 111 | return 112 | } 113 | 114 | //@ts-ignore - Linter knows there *should* not be another type of tag. 115 | baseNodeError(errors.unknownTag(node.name), node) 116 | 117 | //@ts-ignore - ditto 118 | for await (const childNode of node.children ?? []) { 119 | await resolveBaseNode({ 120 | node: childNode, 121 | scope, 122 | isInsideStepTag, 123 | isInsideMessageTag, 124 | isInsideContentTag, 125 | fullPath, 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/tags/content.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_CONTENT_TYPE_ATTR, TAG_NAMES } from '$promptl/constants' 2 | import errors from '$promptl/error/errors' 3 | import { ContentTag } from '$promptl/parser/interfaces' 4 | import { ContentType, ContentTypeTagName } from '$promptl/types' 5 | 6 | import { CompileNodeContext } from '../../types' 7 | 8 | export async function compile( 9 | { 10 | node, 11 | scope, 12 | isInsideStepTag, 13 | isInsideMessageTag, 14 | isInsideContentTag, 15 | fullPath, 16 | resolveBaseNode, 17 | baseNodeError, 18 | popStrayText, 19 | addContent, 20 | }: CompileNodeContext, 21 | attributes: Record, 22 | ) { 23 | if (isInsideContentTag) { 24 | baseNodeError(errors.contentTagInsideContent, node) 25 | } 26 | 27 | for await (const childNode of node.children ?? []) { 28 | await resolveBaseNode({ 29 | node: childNode, 30 | scope, 31 | isInsideStepTag, 32 | isInsideMessageTag, 33 | isInsideContentTag: true, 34 | fullPath, 35 | }) 36 | } 37 | 38 | let type: ContentType 39 | if (node.name === TAG_NAMES.content) { 40 | if (attributes[CUSTOM_CONTENT_TYPE_ATTR] === undefined) { 41 | baseNodeError(errors.messageTagWithoutRole, node) 42 | } 43 | type = attributes[CUSTOM_CONTENT_TYPE_ATTR] as ContentType 44 | delete attributes[CUSTOM_CONTENT_TYPE_ATTR] 45 | } else { 46 | const contentTypeKeysFromTagName = Object.fromEntries( 47 | Object.entries(ContentTypeTagName).map(([k, v]) => [v, k]), 48 | ) 49 | type = 50 | ContentType[ 51 | contentTypeKeysFromTagName[node.name] as keyof typeof ContentType 52 | ] 53 | } 54 | 55 | const stray = popStrayText() 56 | 57 | if (type === ContentType.text && stray.text.length > 0) { 58 | addContent({ 59 | node, 60 | content: { 61 | ...attributes, 62 | type: ContentType.text, 63 | text: stray.text, 64 | _promptlSourceMap: stray.sourceMap, 65 | }, 66 | }) 67 | return 68 | } 69 | 70 | if (type === ContentType.image) { 71 | if (!stray.text.length) { 72 | baseNodeError(errors.emptyContentTag, node) 73 | } 74 | 75 | addContent({ 76 | node, 77 | content: { 78 | ...attributes, 79 | type: ContentType.image, 80 | image: stray.text, 81 | _promptlSourceMap: stray.sourceMap, 82 | }, 83 | }) 84 | return 85 | } 86 | 87 | if (type === ContentType.file) { 88 | if (!stray.text.length) { 89 | baseNodeError(errors.emptyContentTag, node) 90 | } 91 | 92 | const { mime: mimeType, ...rest } = attributes 93 | if (!mimeType) baseNodeError(errors.fileTagWithoutMimeType, node) 94 | 95 | addContent({ 96 | node, 97 | content: { 98 | ...rest, 99 | type: ContentType.file, 100 | file: stray.text, 101 | mimeType: String(mimeType), 102 | _promptlSourceMap: stray.sourceMap, 103 | }, 104 | }) 105 | return 106 | } 107 | 108 | if (type == ContentType.toolCall) { 109 | const { id, name, ...rest } = attributes 110 | if (!id) baseNodeError(errors.toolCallTagWithoutId, node) 111 | if (!name) baseNodeError(errors.toolCallWithoutName, node) 112 | 113 | let toolArguments = rest['arguments'] 114 | delete rest['arguments'] 115 | if (toolArguments && typeof toolArguments === 'string') { 116 | try { 117 | rest['arguments'] = JSON.parse(toolArguments) 118 | } catch { 119 | baseNodeError(errors.invalidToolCallArguments, node) 120 | } 121 | } 122 | 123 | addContent({ 124 | node, 125 | content: { 126 | ...rest, 127 | type: ContentType.toolCall, 128 | toolCallId: String(id), 129 | toolName: String(name), 130 | toolArguments: (toolArguments ?? {}) as Record, 131 | }, 132 | }) 133 | return 134 | } 135 | 136 | baseNodeError(errors.invalidContentType(type), node) 137 | } 138 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/tags/message.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_MESSAGE_ROLE_ATTR, TAG_NAMES } from '$promptl/constants' 2 | import errors from '$promptl/error/errors' 3 | import { MessageTag, TemplateNode } from '$promptl/parser/interfaces' 4 | import { 5 | ContentType, 6 | Message, 7 | MessageContent, 8 | MessageRole, 9 | } from '$promptl/types' 10 | 11 | import { CompileNodeContext } from '../../types' 12 | 13 | export async function compile( 14 | props: CompileNodeContext, 15 | attributes: Record, 16 | ) { 17 | const { 18 | node, 19 | scope, 20 | isInsideStepTag, 21 | isInsideMessageTag, 22 | isInsideContentTag, 23 | fullPath, 24 | resolveBaseNode, 25 | baseNodeError, 26 | groupContent, 27 | groupStrayText, 28 | popContent, 29 | addMessage, 30 | } = props 31 | 32 | if (isInsideContentTag || isInsideMessageTag) { 33 | baseNodeError(errors.messageTagInsideMessage, node) 34 | } 35 | 36 | groupContent() 37 | 38 | let role = node.name as MessageRole 39 | if (node.name === TAG_NAMES.message) { 40 | if (attributes[CUSTOM_MESSAGE_ROLE_ATTR] === undefined) { 41 | baseNodeError(errors.messageTagWithoutRole, node) 42 | } 43 | role = attributes[CUSTOM_MESSAGE_ROLE_ATTR] as MessageRole 44 | delete attributes[CUSTOM_MESSAGE_ROLE_ATTR] 45 | } 46 | 47 | for await (const childNode of node.children ?? []) { 48 | await resolveBaseNode({ 49 | node: childNode, 50 | scope, 51 | isInsideStepTag, 52 | isInsideMessageTag: true, 53 | isInsideContentTag, 54 | fullPath, 55 | }) 56 | } 57 | 58 | groupStrayText() 59 | const content = popContent() 60 | 61 | const message = buildMessage(props as CompileNodeContext, { 62 | role, 63 | attributes, 64 | content, 65 | })! 66 | addMessage(message) 67 | } 68 | 69 | type BuildProps = { 70 | role: R 71 | attributes: Record 72 | content: { node?: TemplateNode; content: MessageContent }[] 73 | } 74 | 75 | function buildMessage( 76 | { node, baseNodeError }: CompileNodeContext, 77 | { role, attributes, content }: BuildProps, 78 | ): Message | undefined { 79 | if (!Object.values(MessageRole).includes(role)) { 80 | baseNodeError(errors.invalidMessageRole(role), node) 81 | } 82 | 83 | if (role !== MessageRole.assistant) { 84 | content.forEach((item) => { 85 | if (item.content.type === ContentType.toolCall) { 86 | baseNodeError(errors.invalidToolCallPlacement, item.node ?? node) 87 | } 88 | }) 89 | } 90 | 91 | const message = { 92 | ...attributes, 93 | role, 94 | content: content.map((item) => item.content), 95 | } as Message 96 | 97 | if (role === MessageRole.user) { 98 | message.name = attributes.name ? String(attributes.name) : undefined 99 | } 100 | 101 | if (role === MessageRole.tool) { 102 | if (attributes.id === undefined) { 103 | baseNodeError(errors.toolMessageWithoutId, node) 104 | } 105 | 106 | if (attributes.name === undefined) { 107 | baseNodeError(errors.toolMessageWithoutName, node) 108 | } 109 | 110 | message.toolId = String(attributes.id) 111 | message.toolName = String(attributes.name) 112 | delete message['id'] 113 | delete message['name'] 114 | } 115 | 116 | return message 117 | } 118 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/tags/ref.ts: -------------------------------------------------------------------------------- 1 | import Scope from '$promptl/compiler/scope' 2 | import errors from '$promptl/error/errors' 3 | import { parse } from '$promptl/parser' 4 | import { Fragment, ReferenceTag } from '$promptl/parser/interfaces' 5 | 6 | import { CompileNodeContext, TemplateNodeWithStatus } from '../../types' 7 | 8 | type ForNodeWithStatus = TemplateNodeWithStatus & { 9 | status: TemplateNodeWithStatus['status'] & { 10 | refAst: Fragment 11 | refFullPath: string 12 | } 13 | } 14 | 15 | export async function compile( 16 | props: CompileNodeContext, 17 | attributes: Record, 18 | ) { 19 | const { 20 | node, 21 | scope, 22 | fullPath, 23 | referencePromptFn, 24 | baseNodeError, 25 | resolveBaseNode, 26 | } = props 27 | 28 | const nodeWithStatus = node as ForNodeWithStatus 29 | nodeWithStatus.status = { 30 | ...nodeWithStatus.status, 31 | scopePointers: scope.getPointers(), 32 | } 33 | 34 | const { path, ...refParameters } = attributes 35 | if (!path) baseNodeError(errors.referenceTagWithoutPrompt, node) 36 | if (typeof path !== 'string') baseNodeError(errors.invalidReferencePath, node) 37 | 38 | if (!nodeWithStatus.status.refAst || !nodeWithStatus.status.refFullPath) { 39 | if (!referencePromptFn) baseNodeError(errors.missingReferenceFunction, node) 40 | 41 | const prompt = await referencePromptFn!(path as string, fullPath) 42 | if (!prompt) baseNodeError(errors.referenceNotFound, node) 43 | const ast = parse(prompt!.content) 44 | nodeWithStatus.status.refAst = ast 45 | nodeWithStatus.status.refFullPath = prompt!.path 46 | } 47 | 48 | const refScope = new Scope(refParameters) 49 | await resolveBaseNode({ 50 | ...props, 51 | node: nodeWithStatus.status.refAst, 52 | scope: refScope, 53 | fullPath: nodeWithStatus.status.refFullPath, 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/tags/scope.test.ts: -------------------------------------------------------------------------------- 1 | import { Adapters, Chain, render } from '$promptl/index' 2 | import { complete } from '$promptl/compiler/test/helpers' 3 | import { removeCommonIndent } from '$promptl/compiler/utils' 4 | import CompileError from '$promptl/error/error' 5 | import { getExpectedError } from '$promptl/test/helpers' 6 | import { 7 | MessageRole, 8 | SystemMessage, 9 | UserMessage, 10 | } from '$promptl/types' 11 | import { describe, expect, it, vi } from 'vitest' 12 | 13 | describe('scope tags', async () => { 14 | it('returns contents as usual', async () => { 15 | const prompt = removeCommonIndent(` 16 | 17 | 18 | This is a text content! 19 | 20 | 21 | `) 22 | 23 | const result = await render({ prompt, adapter: Adapters.default }) 24 | 25 | expect(result.messages.length).toBe(1) 26 | expect(result.messages[0]!.role).toBe(MessageRole.user) 27 | const message = result.messages[0]! as UserMessage 28 | expect(message.content).toEqual([ 29 | { 30 | type: 'text', 31 | text: 'This is a text content!', 32 | }, 33 | ]) 34 | }) 35 | 36 | it('contains its own scope', async () => { 37 | const prompt = removeCommonIndent(` 38 | {{ foo = 'foo' }} 39 | {{ bar = 'bar' }} 40 | 41 | 42 | {{ baz = 'baz' }} 43 | {{ foo = 'new foo' }} 44 | 45 | 46 | {{ foo }} 47 | {{ bar }} 48 | `) 49 | 50 | const result = await render({ prompt, adapter: Adapters.default }) 51 | 52 | expect(result.messages.length).toBe(1) 53 | const message = result.messages[0]! as SystemMessage 54 | expect(message.content).toEqual([ 55 | { 56 | type: 'text', 57 | text: 'foo', 58 | }, 59 | { 60 | type: 'text', 61 | text: 'bar', 62 | }, 63 | ]) 64 | }) 65 | 66 | it('does not automatically inherit variables or parameters from parents', async () => { 67 | const prompt = removeCommonIndent(` 68 | {{ foo = 'bar' }} 69 | 70 | {{ foo }} 71 | 72 | `) 73 | 74 | const action = () => render({ 75 | prompt, 76 | parameters: { foo: 'baz' }, 77 | adapter: Adapters.default 78 | }) 79 | 80 | const error = await getExpectedError(action, CompileError) 81 | expect(error.code).toBe('variable-not-declared') 82 | }) 83 | 84 | it('can inherit parameters from parents if explicitly passed', async () => { 85 | const prompt = removeCommonIndent(` 86 | 87 | {{ foo }} 88 | 89 | `) 90 | 91 | const result = await render({ 92 | prompt, 93 | parameters: { foo: 'bar' }, 94 | adapter: Adapters.default 95 | }) 96 | 97 | expect(result.messages.length).toBe(1) 98 | const message = result.messages[0]! as SystemMessage 99 | expect(message.content).toEqual([ 100 | { 101 | type: 'text', 102 | text: 'bar', 103 | }, 104 | ]) 105 | }) 106 | 107 | it('node state from scope is correctly cached during steps', async () => { 108 | const func = vi.fn() 109 | 110 | const prompt = removeCommonIndent(` 111 | 112 | 113 | {{ func() }} 114 | 115 | {{ for i in [1, 2] }} 116 | 117 | {{ func() }} 118 | 119 | {{ endfor }} 120 | 121 | `) 122 | 123 | const chain = new Chain({ 124 | prompt, 125 | parameters: { func }, 126 | }) 127 | 128 | await complete({ chain }) 129 | 130 | expect(func).toHaveBeenCalledTimes(3) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/tags/scope.ts: -------------------------------------------------------------------------------- 1 | import Scope from '$promptl/compiler/scope' 2 | import { ScopeTag } from '$promptl/parser/interfaces' 3 | 4 | import { CompileNodeContext } from '../../types' 5 | 6 | export async function compile( 7 | props: CompileNodeContext, 8 | attributes: Record, 9 | ) { 10 | const { 11 | node, 12 | resolveBaseNode, 13 | } = props 14 | 15 | const childScope = new Scope(attributes) 16 | 17 | for await (const childNode of node.children) { 18 | await resolveBaseNode({ 19 | ...props, 20 | node: childNode, 21 | scope: childScope, 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/tags/step.test.ts: -------------------------------------------------------------------------------- 1 | import CompileError from '$promptl/error/error' 2 | import { complete, getExpectedError } from "$promptl/compiler/test/helpers"; 3 | import { removeCommonIndent } from "$promptl/compiler/utils"; 4 | import { Chain } from "$promptl/index"; 5 | import { describe, expect, it, vi } from "vitest"; 6 | 7 | describe("step tags", async () => { 8 | it("does not create a variable from response if not specified", async () => { 9 | const mock = vi.fn(); 10 | const prompt = removeCommonIndent(` 11 | 12 | Ensure truthfulness of the following statement, give a reason and a confidence score. 13 | Statement: fake statement 14 | 15 | 16 | Now correct the statement if it is not true. 17 | 18 | `); 19 | 20 | const chain = new Chain({ prompt, parameters: { mock }}); 21 | await complete({ chain, callback: async () => ` 22 | The statement is not true because it is fake. My confidence score is 100. 23 | `.trim()}); 24 | 25 | expect(mock).not.toHaveBeenCalled(); 26 | }); 27 | 28 | it("creates a text variable from response if specified", async () => { 29 | const mock = vi.fn(); 30 | const prompt = removeCommonIndent(` 31 | 32 | Ensure truthfulness of the following statement, give a reason and a confidence score. 33 | Statement: fake statement 34 | 35 | 36 | {{ mock(analysis) }} 37 | Now correct the statement if it is not true. 38 | 39 | `); 40 | 41 | const chain = new Chain({ prompt, parameters: { mock }}); 42 | await complete({ chain, callback: async () => ` 43 | The statement is not true because it is fake. My confidence score is 100. 44 | `.trim()}); 45 | 46 | expect(mock).toHaveBeenCalledWith("The statement is not true because it is fake. My confidence score is 100."); 47 | }); 48 | 49 | it("creates an object variable from response if specified and schema is provided", async () => { 50 | const mock = vi.fn(); 51 | const prompt = removeCommonIndent(` 52 | 53 | Ensure truthfulness of the following statement, give a reason and a confidence score. 54 | Statement: fake statement 55 | 56 | 57 | {{ mock(analysis) }} 58 | {{ if !analysis.truthful && analysis.confidence > 50 }} 59 | Correct the statement taking into account the reason: '{{ analysis.reason }}'. 60 | {{ endif }} 61 | 62 | `); 63 | 64 | const chain = new Chain({ prompt, parameters: { mock }}); 65 | const { messages } = await complete({ chain, callback: async () => ` 66 | { 67 | "truthful": false, 68 | "reason": "It is fake", 69 | "confidence": 100 70 | } 71 | `.trim()}); 72 | 73 | expect(mock).toHaveBeenCalledWith({ 74 | truthful: false, 75 | reason: "It is fake", 76 | confidence: 100 77 | }); 78 | expect(messages[2]!.content).toEqual("Correct the statement taking into account the reason: 'It is fake'."); 79 | }); 80 | 81 | it("fails creating an object variable from response if specified and schema is provided but response is invalid", async () => { 82 | const mock = vi.fn(); 83 | const prompt = removeCommonIndent(` 84 | 85 | Ensure truthfulness of the following statement, give a reason and a confidence score. 86 | Statement: fake statement 87 | 88 | 89 | {{ mock(analysis) }} 90 | {{ if !analysis.truthful && analysis.confidence > 50 }} 91 | Correct the statement taking into account the reason: '{{ analysis.reason }}'. 92 | {{ endif }} 93 | 94 | `); 95 | 96 | const chain = new Chain({ prompt, parameters: { mock }}); 97 | const error = await getExpectedError(() => complete({ chain, callback: async () => ` 98 | Bad JSON. 99 | `.trim()}), CompileError) 100 | expect(error.code).toBe('invalid-step-response-format') 101 | 102 | expect(mock).not.toHaveBeenCalled(); 103 | }); 104 | 105 | it("creates a raw variable from response if specified", async () => { 106 | const mock = vi.fn(); 107 | const prompt = removeCommonIndent(` 108 | 109 | Ensure truthfulness of the following statement, give a reason and a confidence score. 110 | Statement: fake statement 111 | 112 | 113 | {{ mock(analysis) }} 114 | Now correct the statement if it is not true. 115 | 116 | `); 117 | 118 | const chain = new Chain({ prompt, parameters: { mock }}); 119 | await complete({ chain, callback: async () => ` 120 | The statement is not true because it is fake. My confidence score is 100. 121 | `.trim()}); 122 | 123 | expect(mock).toHaveBeenCalledWith({ 124 | role: "assistant", 125 | content: [ 126 | { 127 | type: "text", 128 | text: "The statement is not true because it is fake. My confidence score is 100.", 129 | }, 130 | ], 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/tags/step.ts: -------------------------------------------------------------------------------- 1 | import { tagAttributeIsLiteral } from '$promptl/compiler/utils' 2 | import errors from '$promptl/error/errors' 3 | import { ChainStepTag } from '$promptl/parser/interfaces' 4 | import { Config, ContentType } from '$promptl/types' 5 | 6 | import { CompileNodeContext } from '../../types' 7 | 8 | function isValidConfig(value: unknown): value is Config | undefined { 9 | if (value === undefined) return true 10 | if (Array.isArray(value)) return false 11 | return typeof value === 'object' 12 | } 13 | 14 | export async function compile( 15 | { 16 | node, 17 | scope, 18 | isInsideStepTag, 19 | isInsideMessageTag, 20 | isInsideContentTag, 21 | fullPath, 22 | popStepResponse, 23 | groupContent, 24 | resolveBaseNode, 25 | baseNodeError, 26 | stop, 27 | }: CompileNodeContext, 28 | attributes: Record, 29 | ) { 30 | if (isInsideStepTag) { 31 | baseNodeError(errors.stepTagInsideStep, node) 32 | } 33 | 34 | const stepResponse = popStepResponse() 35 | 36 | const { as: responseVarName, raw: messageVarName, ...config } = attributes 37 | 38 | // The step must be processed. 39 | if (stepResponse === undefined) { 40 | if (!isValidConfig(config)) { 41 | baseNodeError(errors.invalidStepConfig, node) 42 | } 43 | 44 | for await (const childNode of node.children ?? []) { 45 | await resolveBaseNode({ 46 | node: childNode, 47 | scope, 48 | isInsideStepTag: true, 49 | isInsideMessageTag, 50 | isInsideContentTag, 51 | fullPath, 52 | }) 53 | } 54 | 55 | // Stop the compiling process up to this point. 56 | stop(config as Config) 57 | } 58 | 59 | // The step has already been process, this is the continuation of the chain. 60 | 61 | if ('raw' in attributes) { 62 | if (!tagAttributeIsLiteral(node, 'raw')) { 63 | baseNodeError(errors.invalidStaticAttribute('raw'), node) 64 | } 65 | 66 | scope.set(String(messageVarName), stepResponse) 67 | } 68 | 69 | if ('as' in attributes) { 70 | if (!tagAttributeIsLiteral(node, 'as')) { 71 | baseNodeError(errors.invalidStaticAttribute('as'), node) 72 | } 73 | 74 | const textResponse = (stepResponse?.content ?? []).filter(c => c.type === ContentType.text).map(c => c.text).join('') 75 | let responseVarValue = textResponse 76 | 77 | if ("schema" in config) { 78 | try { 79 | responseVarValue = JSON.parse(responseVarValue.trim()) 80 | } catch (error) { 81 | baseNodeError(errors.invalidStepResponseFormat(error as Error), node) 82 | } 83 | } 84 | 85 | scope.set(String(responseVarName), responseVarValue) 86 | } 87 | 88 | groupContent() 89 | } 90 | -------------------------------------------------------------------------------- /src/compiler/base/nodes/text.ts: -------------------------------------------------------------------------------- 1 | import { Text } from '$promptl/parser/interfaces' 2 | 3 | import { CompileNodeContext } from '../types' 4 | 5 | export async function compile({ 6 | node, 7 | addStrayText, 8 | }: CompileNodeContext) { 9 | addStrayText(node.data) 10 | } 11 | -------------------------------------------------------------------------------- /src/compiler/base/types.ts: -------------------------------------------------------------------------------- 1 | import Scope, { ScopePointers } from '$promptl/compiler/scope' 2 | import { TemplateNode } from '$promptl/parser/interfaces' 3 | import { 4 | AssistantMessage, 5 | Config, 6 | Message, 7 | MessageContent, 8 | PromptlSourceRef, 9 | } from '$promptl/types' 10 | import type { Node as LogicalExpression } from 'estree' 11 | 12 | import { ReferencePromptFn, ResolveBaseNodeProps } from '../types' 13 | 14 | export enum NodeType { 15 | Literal = 'Literal', 16 | Identifier = 'Identifier', 17 | ObjectExpression = 'ObjectExpression', 18 | ArrayExpression = 'ArrayExpression', 19 | SequenceExpression = 'SequenceExpression', 20 | LogicalExpression = 'LogicalExpression', 21 | BinaryExpression = 'BinaryExpression', 22 | UnaryExpression = 'UnaryExpression', 23 | AssignmentExpression = 'AssignmentExpression', 24 | UpdateExpression = 'UpdateExpression', 25 | MemberExpression = 'MemberExpression', 26 | ConditionalExpression = 'ConditionalExpression', 27 | CallExpression = 'CallExpression', 28 | ChainExpression = 'ChainExpression', 29 | } 30 | 31 | type RaiseErrorFn = ( 32 | { code, message }: { code: string; message: string }, 33 | node: N, 34 | ) => T 35 | 36 | type NodeStatus = { 37 | completedAs?: unknown 38 | scopePointers?: ScopePointers | undefined 39 | loopIterationIndex?: number 40 | } 41 | 42 | export type TemplateNodeWithStatus = TemplateNode & { 43 | status?: NodeStatus 44 | } 45 | 46 | export type CompileNodeContext = { 47 | node: N 48 | scope: Scope 49 | resolveExpression: ( 50 | expression: LogicalExpression, 51 | scope: Scope, 52 | ) => Promise 53 | resolveBaseNode: (props: ResolveBaseNodeProps) => Promise 54 | baseNodeError: RaiseErrorFn 55 | expressionError: RaiseErrorFn 56 | 57 | isInsideStepTag: boolean 58 | isInsideMessageTag: boolean 59 | isInsideContentTag: boolean 60 | 61 | fullPath: string | undefined 62 | referencePromptFn: ReferencePromptFn | undefined 63 | 64 | setConfig: (config: Config) => void 65 | addMessage: (message: Message, global?: boolean) => void 66 | addStrayText: (text: string, node?: TemplateNode) => void 67 | popStrayText: () => { text: string; sourceMap: PromptlSourceRef[] } 68 | groupStrayText: () => void 69 | addContent: (item: { node?: TemplateNode; content: MessageContent }) => void 70 | popContent: () => { node?: TemplateNode; content: MessageContent }[] 71 | groupContent: () => void 72 | popStepResponse: () => AssistantMessage | undefined 73 | 74 | stop: (config?: Config) => void 75 | } 76 | -------------------------------------------------------------------------------- /src/compiler/chain-serialize.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | import { Adapters } from '$promptl/providers' 4 | import { MessageRole } from '$promptl/types' 5 | import { Chain } from './chain' 6 | import { removeCommonIndent } from './utils' 7 | 8 | describe('serialize chain', async () => { 9 | it('serialize without running step', async () => { 10 | const prompt = removeCommonIndent(` 11 | 12 | Before step 13 | 14 | 15 | After step 16 | 17 | `) 18 | 19 | const chain = new Chain({ prompt, adapter: Adapters.default }) 20 | const serialized = chain.serialize() 21 | 22 | expect(serialized).toEqual({ 23 | rawText: prompt, 24 | scope: { 25 | pointers: {}, 26 | stash: [], 27 | }, 28 | completed: false, 29 | didStart: false, 30 | adapterType: 'default', 31 | compilerOptions: {}, 32 | globalConfig: undefined, 33 | ast: expect.any(Object), 34 | globalMessages: [], 35 | }) 36 | }) 37 | 38 | it('serialize with single step', async () => { 39 | const prompt = removeCommonIndent(` 40 | --- 41 | provider: OpenAI_PATATA 42 | model: gpt-4 43 | --- 44 | {{ foo = 'foo' }} 45 | A message 46 | `) 47 | 48 | const chain = new Chain({ 49 | prompt: removeCommonIndent(prompt), 50 | parameters: {}, 51 | adapter: Adapters.default, 52 | }) 53 | await chain.step() 54 | const serialized = chain.serialize() 55 | 56 | expect(serialized).toEqual({ 57 | rawText: prompt, 58 | scope: { 59 | pointers: { foo: 0 }, 60 | stash: ['foo'], 61 | }, 62 | completed: false, 63 | didStart: true, 64 | adapterType: 'default', 65 | compilerOptions: {}, 66 | globalConfig: { 67 | provider: 'OpenAI_PATATA', 68 | model: 'gpt-4', 69 | }, 70 | ast: expect.any(Object), 71 | globalMessages: [ 72 | { 73 | role: 'system', 74 | content: [{ type: 'text', text: 'A message' }], 75 | }, 76 | ], 77 | }) 78 | }) 79 | 80 | it('serialize 2 steps', async () => { 81 | const prompt = removeCommonIndent(` 82 | 83 | {{foo = 5}} 84 | 85 | 86 | {{foo += 1}} 87 | 88 | 89 | {{foo}} 90 | 91 | `) 92 | 93 | const chain = new Chain({ prompt, adapter: Adapters.openai }) 94 | await chain.step() 95 | await chain.step('First step response') 96 | const serialized = chain.serialize() 97 | 98 | expect(serialized).toEqual({ 99 | rawText: prompt, 100 | scope: { 101 | pointers: { foo: 0 }, 102 | stash: [6], 103 | }, 104 | completed: false, 105 | didStart: true, 106 | adapterType: 'openai', 107 | compilerOptions: { includeSourceMap: false }, 108 | globalConfig: undefined, 109 | ast: expect.any(Object), 110 | globalMessages: [ 111 | { 112 | role: 'assistant', 113 | content: [{ type: 'text', text: 'First step response' }], 114 | }, 115 | ], 116 | }) 117 | }) 118 | 119 | it('serialize parameters', async () => { 120 | const prompt = removeCommonIndent(` 121 | Hello {{name}} 122 | `) 123 | 124 | const chain = new Chain({ 125 | prompt, 126 | parameters: { name: 'Paco' }, 127 | adapter: Adapters.default, 128 | defaultRole: MessageRole.user, 129 | }) 130 | await chain.step() 131 | const serialized = chain.serialize() 132 | 133 | expect(serialized).toEqual({ 134 | rawText: prompt, 135 | adapterType: 'default', 136 | scope: { pointers: { name: 0 }, stash: ['Paco'] }, 137 | completed: false, 138 | didStart: true, 139 | compilerOptions: { defaultRole: 'user' }, 140 | ast: expect.any(Object), 141 | globalConfig: undefined, 142 | globalMessages: [ 143 | { 144 | role: 'user', 145 | content: [{ type: 'text', text: 'Hello Paco' }], 146 | }, 147 | ], 148 | }) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /src/compiler/chain.ts: -------------------------------------------------------------------------------- 1 | import { 2 | deserializeChain, 3 | SerializedProps, 4 | } from '$promptl/compiler/deserializeChain' 5 | import { CHAIN_STEP_ISOLATED_ATTR } from '$promptl/constants' 6 | import { parse } from '$promptl/parser' 7 | import { Fragment } from '$promptl/parser/interfaces' 8 | import { 9 | AdapterMessageType, 10 | Adapters, 11 | ProviderAdapter, 12 | } from '$promptl/providers' 13 | import { ProviderConversation } from '$promptl/providers/adapter' 14 | import { 15 | Config, 16 | ContentType, 17 | Message, 18 | MessageContent, 19 | MessageRole, 20 | } from '$promptl/types' 21 | 22 | import { Compile } from './compile' 23 | import Scope from './scope' 24 | import { CompileOptions } from './types' 25 | 26 | type ChainStep = ProviderConversation & { 27 | completed: boolean 28 | } 29 | 30 | type HasRole = 'role' extends keyof T ? { role?: T['role'] } : {} 31 | export type StepResponse = 32 | | string 33 | | M[] 34 | | (Omit & HasRole) 35 | 36 | type BuildStepResponseContent = { 37 | messages?: Message[] 38 | contents: MessageContent[] | undefined 39 | } 40 | 41 | export class Chain { 42 | public rawText: string 43 | 44 | private _completed: boolean = false 45 | private adapter: ProviderAdapter 46 | private ast: Fragment 47 | private compileOptions: CompileOptions 48 | private didStart: boolean = false 49 | private globalConfig: Config | undefined 50 | private globalMessages: Message[] = [] 51 | private scope: Scope 52 | private wasLastStepIsolated: boolean = false 53 | 54 | static deserialize(args: SerializedProps) { 55 | return deserializeChain(args) 56 | } 57 | 58 | constructor({ 59 | prompt, 60 | parameters = {}, 61 | serialized, 62 | adapter = Adapters.openai as ProviderAdapter, 63 | ...compileOptions 64 | }: { 65 | prompt: string 66 | parameters?: Record 67 | adapter?: ProviderAdapter 68 | serialized?: { 69 | ast?: Fragment 70 | scope?: Scope 71 | didStart?: boolean 72 | completed?: boolean 73 | globalConfig?: Config 74 | globalMessages?: Message[] 75 | } 76 | } & CompileOptions) { 77 | this.rawText = prompt 78 | this.ast = serialized?.ast ?? parse(prompt) 79 | this.scope = serialized?.scope ?? new Scope(parameters) 80 | this.didStart = serialized?.didStart ?? false 81 | this._completed = serialized?.completed ?? false 82 | this.globalConfig = serialized?.globalConfig 83 | this.globalMessages = serialized?.globalMessages ?? [] 84 | this.adapter = adapter 85 | this.compileOptions = compileOptions 86 | 87 | if (this.adapter !== Adapters.default) { 88 | this.compileOptions.includeSourceMap = false 89 | } 90 | } 91 | 92 | async step(response?: StepResponse): Promise> { 93 | if (this._completed) { 94 | throw new Error('The chain has already completed') 95 | } 96 | 97 | if (!this.didStart && response !== undefined) { 98 | throw new Error('A response is not allowed before the chain has started') 99 | } 100 | 101 | if (this.didStart && response === undefined) { 102 | throw new Error('A response is required to continue the chain') 103 | } 104 | 105 | this.didStart = true 106 | 107 | const responseContent = this.buildStepResponseContent(response) 108 | const newGlobalMessages = this.buildGlobalMessages(responseContent) 109 | 110 | if (newGlobalMessages.length > 0) { 111 | this.globalMessages = [ 112 | ...this.globalMessages, 113 | ...(newGlobalMessages as Message[]), 114 | ] 115 | } 116 | 117 | const compile = new Compile({ 118 | ast: this.ast, 119 | rawText: this.rawText, 120 | globalScope: this.scope, 121 | stepResponse: responseContent.contents, 122 | ...this.compileOptions, 123 | }) 124 | 125 | const { completed, scopeStash, ast, messages, globalConfig, stepConfig } = 126 | await compile.run() 127 | 128 | this.scope = Scope.withStash(scopeStash).copy(this.scope.getPointers()) 129 | this.ast = ast 130 | 131 | this.globalConfig = globalConfig ?? this.globalConfig 132 | 133 | // If it returned a message, there is still a final step to be taken 134 | this._completed = completed && !messages.length 135 | 136 | const config = { 137 | ...this.globalConfig, 138 | ...stepConfig, 139 | } 140 | 141 | this.wasLastStepIsolated = !!config[CHAIN_STEP_ISOLATED_ATTR] 142 | 143 | const stepMessages = [ 144 | ...(this.wasLastStepIsolated ? [] : this.globalMessages), 145 | ...messages, 146 | ] 147 | 148 | if (!this.wasLastStepIsolated) { 149 | this.globalMessages.push(...messages) 150 | } 151 | 152 | return { 153 | ...this.adapter.fromPromptl({ 154 | messages: stepMessages, 155 | config, 156 | }), 157 | completed: this._completed, 158 | } 159 | } 160 | 161 | serialize() { 162 | return { 163 | ast: this.ast, 164 | scope: this.scope.serialize(), 165 | didStart: this.didStart, 166 | completed: this._completed, 167 | adapterType: this.adapter.type, 168 | compilerOptions: this.compileOptions, 169 | globalConfig: this.globalConfig, 170 | globalMessages: this.globalMessages, 171 | rawText: this.rawText, 172 | } 173 | } 174 | 175 | get globalMessagesCount(): number { 176 | return this.globalMessages.length 177 | } 178 | 179 | get completed(): boolean { 180 | return this._completed 181 | } 182 | 183 | private buildStepResponseContent( 184 | response?: StepResponse | M[], 185 | ): BuildStepResponseContent { 186 | if (response == undefined) return { contents: undefined } 187 | if (typeof response === 'string') { 188 | return { contents: [{ text: response, type: ContentType.text }] } 189 | } 190 | 191 | if (Array.isArray(response)) { 192 | const converted = this.adapter.toPromptl({ 193 | config: this.globalConfig ?? {}, 194 | messages: response as M[], 195 | }) 196 | const contents = converted.messages.flatMap((m) => m.content) 197 | return { messages: converted.messages as Message[], contents } 198 | } 199 | 200 | const responseMessage = { 201 | ...response, 202 | role: 'role' in response ? response.role : MessageRole.assistant, 203 | } as M 204 | 205 | const convertedMessages = this.adapter.toPromptl({ 206 | config: this.globalConfig ?? {}, 207 | messages: [responseMessage], 208 | }) 209 | 210 | return { contents: convertedMessages.messages[0]!.content } 211 | } 212 | 213 | private buildGlobalMessages( 214 | buildStepResponseContent: BuildStepResponseContent, 215 | ) { 216 | const { messages, contents } = buildStepResponseContent 217 | 218 | if (this.wasLastStepIsolated) return [] 219 | if (!contents) return [] 220 | 221 | if (messages) return messages 222 | 223 | return [ 224 | { 225 | role: MessageRole.assistant, 226 | content: contents ?? [], 227 | }, 228 | ] 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/compiler/deserializeChain.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | import { removeCommonIndent } from './utils' 4 | import { Adapters } from '$promptl/providers' 5 | import { Chain } from './chain' 6 | 7 | describe('deserialize chain', async () => { 8 | it('get final step from serialized chain', async () => { 9 | const prompt = removeCommonIndent(` 10 | 11 | {{foo = 5}} 12 | 13 | 14 | {{foo += 1}} 15 | 16 | 17 | 18 | The final result is {{foo}} 19 | 20 | 21 | `) 22 | 23 | const chain = new Chain({ prompt, adapter: Adapters.openai }) 24 | 25 | await chain.step() 26 | await chain.step('First step response') 27 | const serializedChain = chain.serialize() 28 | const serialized = JSON.stringify(serializedChain) 29 | 30 | // In another context we deserialize existing chain 31 | const deserializedChain = Chain.deserialize({ serialized }) 32 | const { messages } = await deserializedChain!.step('Last step') 33 | expect(messages[messages.length - 1]).toEqual({ 34 | role: 'assistant', 35 | tool_calls: undefined, 36 | content: [{ text: 'The final result is 6', type: 'text' }], 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/compiler/deserializeChain.ts: -------------------------------------------------------------------------------- 1 | import { SerializedChain } from '$promptl/compiler' 2 | import { Chain } from '$promptl/compiler/chain' 3 | import Scope from '$promptl/compiler/scope' 4 | import { getAdapter } from '$promptl/providers' 5 | 6 | function safeSerializedData(data: string | SerializedChain): SerializedChain { 7 | try { 8 | const serialized = 9 | typeof data === 'string' 10 | ? JSON.parse(data) 11 | : typeof data === 'object' 12 | ? data 13 | : {} 14 | 15 | const compilerOptions = serialized.compilerOptions || {} 16 | const globalConfig = serialized.globalConfig 17 | const globalMessages = serialized.globalMessages || [] 18 | 19 | if ( 20 | typeof serialized !== 'object' || 21 | typeof serialized.ast !== 'object' || 22 | typeof serialized.scope !== 'object' || 23 | typeof serialized.didStart !== 'boolean' || 24 | typeof serialized.completed !== 'boolean' || 25 | typeof serialized.adapterType !== 'string' || 26 | typeof serialized.rawText !== 'string' 27 | ) { 28 | throw new Error() 29 | } 30 | return { 31 | rawText: serialized.rawText, 32 | ast: serialized.ast, 33 | scope: serialized.scope, 34 | didStart: serialized.didStart, 35 | completed: serialized.completed, 36 | adapterType: serialized.adapterType, 37 | compilerOptions, 38 | globalConfig, 39 | globalMessages, 40 | } 41 | } catch { 42 | throw new Error('Invalid serialized chain data') 43 | } 44 | } 45 | 46 | export type SerializedProps = { 47 | serialized: string | SerializedChain | undefined | null 48 | } 49 | export function deserializeChain({ 50 | serialized, 51 | }: SerializedProps): Chain | undefined { 52 | if (!serialized) return undefined 53 | 54 | const { 55 | rawText, 56 | ast, 57 | scope: serializedScope, 58 | didStart, 59 | completed, 60 | adapterType, 61 | compilerOptions, 62 | globalConfig, 63 | globalMessages, 64 | } = safeSerializedData(serialized) 65 | 66 | const adapter = getAdapter(adapterType) 67 | const scope = new Scope() 68 | scope.setStash(serializedScope.stash) 69 | scope.setPointers(serializedScope.pointers) 70 | 71 | return new Chain({ 72 | prompt: rawText, 73 | serialized: { 74 | ast, 75 | scope, 76 | didStart, 77 | completed, 78 | globalConfig, 79 | globalMessages, 80 | }, 81 | adapter, 82 | ...compilerOptions, 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /src/compiler/errors.test.ts: -------------------------------------------------------------------------------- 1 | import CompileError from '$promptl/error/error' 2 | import { describe, expect, it } from 'vitest' 3 | 4 | import { scan, render } from '.' 5 | import { removeCommonIndent } from './utils' 6 | 7 | const getExpectedError = async ( 8 | fn: () => Promise, 9 | errorMessage: string, 10 | ): Promise => { 11 | try { 12 | await fn() 13 | } catch (err) { 14 | expect(err).toBeInstanceOf(CompileError) 15 | return err as CompileError 16 | } 17 | throw new Error(errorMessage) 18 | } 19 | 20 | const expectBothErrors = async ({ 21 | code, 22 | prompt, 23 | }: { 24 | code: string 25 | prompt: string 26 | }) => { 27 | const compileError = await getExpectedError(async () => { 28 | await render({ 29 | prompt: removeCommonIndent(prompt), 30 | parameters: {}, 31 | }) 32 | }, `Expected compile to throw '${code}'`) 33 | expect(compileError.code).toBe(code) 34 | 35 | const metadata = await scan({ 36 | prompt: removeCommonIndent(prompt), 37 | }) 38 | if (metadata.errors.length === 0) { 39 | throw new Error(`Expected scan to throw '${code}'`) 40 | } 41 | const metadataError = metadata.errors[0]! 42 | 43 | expect(metadataError.code).toBe(code) 44 | expect(compileError.code).toBe(metadataError.code) 45 | } 46 | 47 | describe(`all compilation errors that don't require value resolution are caught both in compile and scan`, () => { 48 | it.todo('unsupported-base-node-type') // This one requires the parser to return an unsupported node type, which is not reproducible 49 | 50 | it('variable-already-declared', async () => { 51 | const prompt = ` 52 | {{foo = 5}} 53 | {{ for foo in [1, 2, 3] }} 54 | {{foo}} 55 | {{ endfor }} 56 | ` 57 | 58 | await expectBothErrors({ 59 | code: 'variable-already-declared', 60 | prompt, 61 | }) 62 | }) 63 | 64 | it.todo('invalid-object-key', async () => { 65 | // Parser does not even parse this prompt 66 | const prompt = ` 67 | {{ foo = 5 }} 68 | {{ { [1, 2, 3]: 'bar' } }} 69 | ` 70 | 71 | await expectBothErrors({ 72 | code: 'invalid-object-key', 73 | prompt, 74 | }) 75 | }) 76 | 77 | it.todo('unsupported-operator', async () => { 78 | // Parser does not even parse this prompt 79 | const prompt = ` 80 | {{ foo = 5 ++ 2 }} 81 | ` 82 | 83 | await expectBothErrors({ 84 | code: 'unsupported-operator', 85 | prompt, 86 | }) 87 | }) 88 | 89 | it('invalid-assignment', async () => { 90 | const prompt = ` 91 | {{ [ foo ] = [ 5 ] }} 92 | ` 93 | 94 | await expectBothErrors({ 95 | code: 'invalid-assignment', 96 | prompt, 97 | }) 98 | }) 99 | 100 | it('invalid-tool-call-placement', async () => { 101 | const prompt = ` 102 | 103 | 104 | 105 | ` 106 | 107 | await expectBothErrors({ 108 | code: 'invalid-tool-call-placement', 109 | prompt, 110 | }) 111 | }) 112 | 113 | it('step-tag-inside-step', async () => { 114 | const prompt = ` 115 | 116 | 117 | Foo 118 | 119 | 120 | ` 121 | 122 | await expectBothErrors({ 123 | code: 'step-tag-inside-step', 124 | prompt, 125 | }) 126 | }) 127 | 128 | it('message-tag-inside-message', async () => { 129 | const prompt = ` 130 | 131 | 132 | Foo 133 | 134 | 135 | ` 136 | 137 | await expectBothErrors({ 138 | code: 'message-tag-inside-message', 139 | prompt, 140 | }) 141 | }) 142 | 143 | it('content-tag-inside-content', async () => { 144 | const prompt = ` 145 | 146 | 147 | Foo 148 | 149 | 150 | ` 151 | 152 | await expectBothErrors({ 153 | code: 'content-tag-inside-content', 154 | prompt, 155 | }) 156 | }) 157 | 158 | it('tool-call-tag-without-id', async () => { 159 | const prompt = ` 160 | 161 | 162 | 163 | ` 164 | 165 | await expectBothErrors({ 166 | code: 'tool-call-tag-without-id', 167 | prompt, 168 | }) 169 | }) 170 | 171 | it('tool-message-without-id', async () => { 172 | const prompt = ` 173 | 174 | Foo 175 | 176 | ` 177 | 178 | await expectBothErrors({ 179 | code: 'tool-message-without-id', 180 | prompt, 181 | }) 182 | }) 183 | 184 | it('tool-call-without-name', async () => { 185 | const prompt = ` 186 | 187 | 188 | 189 | ` 190 | 191 | await expectBothErrors({ 192 | code: 'tool-call-without-name', 193 | prompt, 194 | }) 195 | }) 196 | 197 | it('message-tag-without-role', async () => { 198 | const prompt = ` 199 | 200 | Foo 201 | 202 | ` 203 | 204 | await expectBothErrors({ 205 | code: 'message-tag-without-role', 206 | prompt, 207 | }) 208 | }) 209 | }) 210 | -------------------------------------------------------------------------------- /src/compiler/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AdapterMessageType, 3 | Adapters, 4 | ProviderAdapter, 5 | } from '$promptl/providers' 6 | import { ProviderConversation } from '$promptl/providers/adapter' 7 | import { ConversationMetadata, Message } from '$promptl/types' 8 | import { z } from 'zod' 9 | 10 | import { Chain } from './chain' 11 | import { Scan } from './scan' 12 | import type { CompileOptions, Document, ReferencePromptFn } from './types' 13 | import { Fragment } from '$promptl/parser/interfaces' 14 | 15 | export async function render({ 16 | prompt, 17 | parameters = {}, 18 | adapter = Adapters.openai as ProviderAdapter, 19 | ...compileOptions 20 | }: { 21 | prompt: string 22 | parameters?: Record 23 | adapter?: ProviderAdapter 24 | } & CompileOptions): Promise> { 25 | const iterator = new Chain({ prompt, parameters, adapter, ...compileOptions }) 26 | const { messages, config } = await iterator.step() 27 | return { messages, config } 28 | } 29 | 30 | export function createChain({ 31 | prompt, 32 | parameters, 33 | ...compileOptions 34 | }: { 35 | prompt: string 36 | parameters: Record 37 | } & CompileOptions): Chain { 38 | return new Chain({ prompt, parameters, ...compileOptions }) 39 | } 40 | 41 | export function scan({ 42 | prompt, 43 | serialized, 44 | fullPath, 45 | referenceFn, 46 | withParameters, 47 | configSchema, 48 | requireConfig, 49 | }: { 50 | prompt: string 51 | serialized?: Fragment 52 | fullPath?: string 53 | referenceFn?: ReferencePromptFn 54 | withParameters?: string[] 55 | configSchema?: z.ZodType 56 | requireConfig?: boolean 57 | }): Promise { 58 | return new Scan({ 59 | document: { path: fullPath ?? '', content: prompt }, 60 | serialized, 61 | referenceFn, 62 | withParameters, 63 | configSchema, 64 | requireConfig, 65 | }).run() 66 | } 67 | 68 | type SerializedChain = ReturnType 69 | 70 | export { Chain, type SerializedChain, type Document, type ReferencePromptFn } 71 | -------------------------------------------------------------------------------- /src/compiler/logic/index.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from 'estree' 2 | 3 | import { nodeResolvers, updateScopeContextResolvers } from './nodes' 4 | import type { 5 | NodeType, 6 | ResolveNodeProps, 7 | UpdateScopeContextProps, 8 | } from './types' 9 | 10 | /** 11 | * Given a node, calculates the resulting value. 12 | */ 13 | export async function resolveLogicNode(props: ResolveNodeProps) { 14 | const type = props.node.type as NodeType 15 | if (!nodeResolvers[type]) { 16 | throw new Error(`Unknown node type: ${type}`) 17 | } 18 | 19 | const nodeResolver = nodeResolvers[props.node.type as NodeType] 20 | return nodeResolver(props) 21 | } 22 | 23 | /** 24 | * Given a node, keeps track of the defined variables. 25 | */ 26 | export async function updateScopeContextForNode( 27 | props: UpdateScopeContextProps, 28 | ) { 29 | const type = props.node.type as NodeType 30 | if (!nodeResolvers[type]) { 31 | throw new Error(`Unknown node type: ${type}`) 32 | } 33 | 34 | const updateScopeContextFn = 35 | updateScopeContextResolvers[props.node.type as NodeType] 36 | return updateScopeContextFn(props) 37 | } 38 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/arrayExpression.ts: -------------------------------------------------------------------------------- 1 | import { 2 | resolveLogicNode, 3 | updateScopeContextForNode, 4 | } from '$promptl/compiler/logic' 5 | import type { 6 | ResolveNodeProps, 7 | UpdateScopeContextProps, 8 | } from '$promptl/compiler/logic/types' 9 | import { isIterable } from '$promptl/compiler/utils' 10 | import errors from '$promptl/error/errors' 11 | import type { ArrayExpression } from 'estree' 12 | 13 | /** 14 | * ### ArrayExpression 15 | * Returns an array of values 16 | */ 17 | 18 | export async function resolve({ 19 | node, 20 | ...props 21 | }: ResolveNodeProps) { 22 | const { raiseError } = props 23 | const resolvedArray = [] 24 | for (const element of node.elements) { 25 | if (!element) continue 26 | if (element.type !== 'SpreadElement') { 27 | const value = await resolveLogicNode({ 28 | node: element, 29 | ...props, 30 | }) 31 | resolvedArray.push(value) 32 | continue 33 | } 34 | 35 | const spreadObject = await resolveLogicNode({ 36 | node: element.argument, 37 | ...props, 38 | }) 39 | 40 | if (!isIterable(spreadObject)) { 41 | raiseError(errors.invalidSpreadInArray(typeof spreadObject), element) 42 | } 43 | 44 | for await (const value of spreadObject as Iterable) { 45 | resolvedArray.push(value) 46 | } 47 | } 48 | 49 | return resolvedArray 50 | } 51 | 52 | export function updateScopeContext({ 53 | node, 54 | ...props 55 | }: UpdateScopeContextProps) { 56 | for (const element of node.elements) { 57 | if (!element) continue 58 | 59 | updateScopeContextForNode({ 60 | node: element.type === 'SpreadElement' ? element.argument : element, 61 | ...props, 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/assignmentExpression.ts: -------------------------------------------------------------------------------- 1 | import { ASSIGNMENT_OPERATOR_METHODS } from '$promptl/compiler/logic/operators' 2 | import type { 3 | ResolveNodeProps, 4 | UpdateScopeContextProps, 5 | } from '$promptl/compiler/logic/types' 6 | import errors from '$promptl/error/errors' 7 | import type { 8 | AssignmentExpression, 9 | AssignmentOperator, 10 | Identifier, 11 | MemberExpression, 12 | } from 'estree' 13 | 14 | import { resolveLogicNode, updateScopeContextForNode } from '..' 15 | 16 | /** 17 | * ### AssignmentExpression 18 | * Represents an assignment or update to a variable or property. Returns the newly assigned value. 19 | * The assignment can be made to an existing variable or property, or to a new one. Assignments to constants are not allowed. 20 | * 21 | * Examples: `foo = 1` `obj.foo = 'bar'` `foo += 1` 22 | */ 23 | export async function resolve({ 24 | node, 25 | scope, 26 | raiseError, 27 | ...props 28 | }: ResolveNodeProps) { 29 | const assignmentOperator = node.operator 30 | if (!(assignmentOperator in ASSIGNMENT_OPERATOR_METHODS)) { 31 | raiseError(errors.unsupportedOperator(assignmentOperator), node) 32 | } 33 | const assignmentMethod = ASSIGNMENT_OPERATOR_METHODS[assignmentOperator]! 34 | 35 | const assignmentValue = await resolveLogicNode({ 36 | node: node.right, 37 | scope, 38 | raiseError, 39 | ...props, 40 | }) 41 | 42 | if (node.left.type === 'Identifier') { 43 | await assignToVariable({ 44 | assignmentOperator, 45 | assignmentMethod, 46 | assignmentValue, 47 | node: node.left, 48 | scope, 49 | raiseError, 50 | ...props, 51 | }) 52 | return 53 | } 54 | 55 | if (node.left.type === 'MemberExpression') { 56 | await assignToProperty({ 57 | assignmentOperator, 58 | assignmentMethod, 59 | assignmentValue, 60 | node: node.left, 61 | scope, 62 | raiseError, 63 | ...props, 64 | }) 65 | return 66 | } 67 | 68 | raiseError(errors.invalidAssignment, node) 69 | } 70 | 71 | async function assignToVariable({ 72 | assignmentOperator, 73 | assignmentMethod, 74 | assignmentValue, 75 | node, 76 | scope, 77 | raiseError, 78 | }: ResolveNodeProps & { 79 | assignmentOperator: AssignmentOperator 80 | assignmentMethod: (typeof ASSIGNMENT_OPERATOR_METHODS)[keyof typeof ASSIGNMENT_OPERATOR_METHODS] 81 | assignmentValue: unknown 82 | }) { 83 | const assignedVariableName = node.name 84 | 85 | if (assignmentOperator != '=' && !scope.exists(assignedVariableName)) { 86 | raiseError(errors.variableNotDeclared(assignedVariableName), node) 87 | } 88 | 89 | const updatedValue = assignmentMethod( 90 | scope.exists(assignedVariableName) 91 | ? scope.get(assignedVariableName) 92 | : undefined, 93 | assignmentValue, 94 | ) 95 | 96 | scope.set(assignedVariableName, updatedValue) 97 | return updatedValue 98 | } 99 | 100 | async function assignToProperty({ 101 | assignmentOperator, 102 | assignmentMethod, 103 | assignmentValue, 104 | node, 105 | ...props 106 | }: ResolveNodeProps & { 107 | assignmentOperator: AssignmentOperator 108 | assignmentMethod: (typeof ASSIGNMENT_OPERATOR_METHODS)[keyof typeof ASSIGNMENT_OPERATOR_METHODS] 109 | assignmentValue: unknown 110 | }) { 111 | const { raiseError } = props 112 | const object = (await resolveLogicNode({ 113 | node: node.object, 114 | ...props, 115 | })) as { [key: string]: any } 116 | 117 | const property = ( 118 | node.computed 119 | ? await resolveLogicNode({ 120 | node: node.property, 121 | ...props, 122 | }) 123 | : (node.property as Identifier).name 124 | ) as string 125 | 126 | if (assignmentOperator != '=' && !(property in object)) { 127 | raiseError(errors.propertyNotExists(property), node) 128 | } 129 | 130 | const originalValue = object[property] 131 | const updatedValue = assignmentMethod(originalValue, assignmentValue) 132 | object[property] = updatedValue 133 | return updatedValue 134 | } 135 | 136 | export function updateScopeContext({ 137 | node, 138 | scopeContext, 139 | raiseError, 140 | }: UpdateScopeContextProps) { 141 | const assignmentOperator = node.operator 142 | if (!(assignmentOperator in ASSIGNMENT_OPERATOR_METHODS)) { 143 | raiseError(errors.unsupportedOperator(assignmentOperator), node) 144 | } 145 | 146 | updateScopeContextForNode({ node: node.right, scopeContext, raiseError }) 147 | 148 | if (node.left.type === 'Identifier') { 149 | // Variable assignment 150 | const assignedVariableName = (node.left as Identifier).name 151 | if (assignmentOperator != '=') { 152 | // Update an existing variable 153 | if (!scopeContext.definedVariables.has(assignedVariableName)) { 154 | scopeContext.usedUndefinedVariables.add(assignedVariableName) 155 | } 156 | } 157 | scopeContext.definedVariables.add(assignedVariableName) 158 | return 159 | } 160 | 161 | if (node.left.type === 'MemberExpression') { 162 | updateScopeContextForNode({ node: node.left, scopeContext, raiseError }) 163 | return 164 | } 165 | 166 | raiseError(errors.invalidAssignment, node) 167 | } 168 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/binaryExpression.ts: -------------------------------------------------------------------------------- 1 | import { BINARY_OPERATOR_METHODS } from '$promptl/compiler/logic/operators' 2 | import type { 3 | ResolveNodeProps, 4 | UpdateScopeContextProps, 5 | } from '$promptl/compiler/logic/types' 6 | import errors from '$promptl/error/errors' 7 | import type { BinaryExpression, LogicalExpression } from 'estree' 8 | 9 | import { resolveLogicNode, updateScopeContextForNode } from '..' 10 | 11 | /** 12 | * ### BinaryExpression 13 | * Represents a simple operation between two operands. 14 | * 15 | * Example: `{a > b}` 16 | */ 17 | export async function resolve({ 18 | node, 19 | raiseError, 20 | ...props 21 | }: ResolveNodeProps) { 22 | const binaryOperator = node.operator 23 | if (!(binaryOperator in BINARY_OPERATOR_METHODS)) { 24 | raiseError(errors.unsupportedOperator(binaryOperator), node) 25 | } 26 | const leftOperand = await resolveLogicNode({ 27 | node: node.left, 28 | raiseError, 29 | ...props, 30 | }) 31 | const rightOperand = await resolveLogicNode({ 32 | node: node.right, 33 | raiseError, 34 | ...props, 35 | }) 36 | 37 | return BINARY_OPERATOR_METHODS[binaryOperator]?.(leftOperand, rightOperand) 38 | } 39 | 40 | export function updateScopeContext({ 41 | node, 42 | ...props 43 | }: UpdateScopeContextProps) { 44 | const binaryOperator = node.operator 45 | if (!(binaryOperator in BINARY_OPERATOR_METHODS)) { 46 | props.raiseError(errors.unsupportedOperator(binaryOperator), node) 47 | } 48 | 49 | updateScopeContextForNode({ node: node.left, ...props }) 50 | updateScopeContextForNode({ node: node.right, ...props }) 51 | } 52 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/callExpression.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ResolveNodeProps, 3 | UpdateScopeContextProps, 4 | } from '$promptl/compiler/logic/types' 5 | import CompileError from '$promptl/error/error' 6 | import errors from '$promptl/error/errors' 7 | import type { SimpleCallExpression } from 'estree' 8 | 9 | import { resolveLogicNode, updateScopeContextForNode } from '..' 10 | 11 | /** 12 | * ### CallExpression 13 | * Represents a method call. 14 | * 15 | * Examples: `foo()` `foo.bar()` 16 | */ 17 | export async function resolve(props: ResolveNodeProps) { 18 | const { node, raiseError } = props 19 | const method = (await resolveLogicNode({ 20 | ...props, 21 | node: node.callee, 22 | })) as Function 23 | 24 | if (typeof method !== 'function') { 25 | raiseError(errors.notAFunction(typeof method), node) 26 | } 27 | 28 | const args = await resolveArgs(props) 29 | return await runMethod({ ...props, method, args }) 30 | } 31 | 32 | function resolveArgs( 33 | props: ResolveNodeProps, 34 | ): Promise { 35 | const { node } = props 36 | return Promise.all( 37 | node.arguments.map((arg) => 38 | resolveLogicNode({ 39 | ...props, 40 | node: arg, 41 | }), 42 | ), 43 | ) 44 | } 45 | 46 | async function runMethod({ 47 | method, 48 | args, 49 | node, 50 | raiseError, 51 | }: ResolveNodeProps & { 52 | method: Function 53 | args: unknown[] 54 | }) { 55 | try { 56 | return await method(...args) 57 | } catch (error: unknown) { 58 | if (error instanceof CompileError) throw error 59 | raiseError(errors.functionCallError(error), node) 60 | } 61 | } 62 | 63 | export function updateScopeContext({ 64 | node, 65 | ...props 66 | }: UpdateScopeContextProps) { 67 | updateScopeContextForNode({ node: node.callee, ...props }) 68 | for (const arg of node.arguments) { 69 | updateScopeContextForNode({ node: arg, ...props }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/chainExpression.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ResolveNodeProps, 3 | UpdateScopeContextProps, 4 | } from '$promptl/compiler/logic/types' 5 | import type { ChainExpression } from 'estree' 6 | 7 | import { resolveLogicNode, updateScopeContextForNode } from '..' 8 | 9 | /** 10 | * ### Chain Expression 11 | * Represents a chain of operations. This is only being used for optional member expressions '?.' 12 | */ 13 | export async function resolve({ 14 | node, 15 | ...props 16 | }: ResolveNodeProps) { 17 | return resolveLogicNode({ 18 | node: node.expression, 19 | ...props, 20 | }) 21 | } 22 | 23 | export function updateScopeContext({ 24 | node, 25 | ...props 26 | }: UpdateScopeContextProps) { 27 | updateScopeContextForNode({ node: node.expression, ...props }) 28 | } 29 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/conditionalExpression.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ResolveNodeProps, 3 | UpdateScopeContextProps, 4 | } from '$promptl/compiler/logic/types' 5 | import type { ConditionalExpression } from 'estree' 6 | 7 | import { resolveLogicNode, updateScopeContextForNode } from '..' 8 | 9 | /** 10 | * ### ConditionalExpression 11 | * Represents a ternary operation. 12 | * 13 | * Example: `a ? b : c` 14 | */ 15 | export async function resolve({ 16 | node, 17 | ...props 18 | }: ResolveNodeProps) { 19 | const condition = await resolveLogicNode({ node: node.test, ...props }) 20 | return await resolveLogicNode({ 21 | node: condition ? node.consequent : node.alternate, 22 | ...props, 23 | }) 24 | } 25 | 26 | export function updateScopeContext({ 27 | node, 28 | ...props 29 | }: UpdateScopeContextProps) { 30 | updateScopeContextForNode({ node: node.test, ...props }) 31 | updateScopeContextForNode({ node: node.consequent, ...props }) 32 | updateScopeContextForNode({ node: node.alternate, ...props }) 33 | } 34 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/identifier.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ResolveNodeProps, 3 | UpdateScopeContextProps, 4 | } from '$promptl/compiler/logic/types' 5 | import errors from '$promptl/error/errors' 6 | import type { Identifier } from 'estree' 7 | 8 | /** 9 | * ### Identifier 10 | * Represents a variable from the scope. 11 | */ 12 | export async function resolve({ 13 | node, 14 | scope, 15 | raiseError, 16 | }: ResolveNodeProps) { 17 | if (!scope.exists(node.name)) { 18 | raiseError(errors.variableNotDeclared(node.name), node) 19 | } 20 | return scope.get(node.name) 21 | } 22 | 23 | export function updateScopeContext({ 24 | node, 25 | scopeContext, 26 | raiseError, 27 | }: UpdateScopeContextProps) { 28 | if (!scopeContext.definedVariables.has(node.name)) { 29 | if (scopeContext.onlyPredefinedVariables === undefined) { 30 | scopeContext.usedUndefinedVariables.add(node.name) 31 | return 32 | } 33 | if (!scopeContext.onlyPredefinedVariables.has(node.name)) { 34 | raiseError(errors.variableNotDefined(node.name), node) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NodeType, 3 | ResolveNodeProps, 4 | UpdateScopeContextProps, 5 | } from '$promptl/compiler/logic/types' 6 | import { Node } from 'estree' 7 | 8 | import { 9 | resolve as resolveArrayExpression, 10 | updateScopeContext as updateArrayScopeContext, 11 | } from './arrayExpression' 12 | import { 13 | resolve as resolveAssignmentExpression, 14 | updateScopeContext as updateAssignmentScopeContext, 15 | } from './assignmentExpression' 16 | import { 17 | resolve as resolveBinaryExpression, 18 | updateScopeContext as updateBinaryScopeContext, 19 | } from './binaryExpression' 20 | import { 21 | resolve as resolveCallExpression, 22 | updateScopeContext as updateCallScopeContext, 23 | } from './callExpression' 24 | import { 25 | resolve as resolveChainExpression, 26 | updateScopeContext as updateChainScopeContext, 27 | } from './chainExpression' 28 | import { 29 | resolve as resolveConditionalExpression, 30 | updateScopeContext as updateConditionalScopeContext, 31 | } from './conditionalExpression' 32 | import { 33 | resolve as resolveIdentifier, 34 | updateScopeContext as updateIdentifierScopeContext, 35 | } from './identifier' 36 | import { 37 | resolve as resolveLiteral, 38 | updateScopeContext as updateLiteralScopeContext, 39 | } from './literal' 40 | import { 41 | resolve as resolveMemberExpression, 42 | updateScopeContext as updateMemberScopeContext, 43 | } from './memberExpression' 44 | import { 45 | resolve as resolveObjectExpression, 46 | updateScopeContext as updateObjectScopeContext, 47 | } from './objectExpression' 48 | import { 49 | resolve as resolveSequenceExpression, 50 | updateScopeContext as updateSequenceScopeContext, 51 | } from './sequenceExpression' 52 | import { 53 | resolve as resolveUnaryExpression, 54 | updateScopeContext as updateUnaryScopeContext, 55 | } from './unaryExpression' 56 | import { 57 | resolve as resolveUpdateExpression, 58 | updateScopeContext as updateUpdateScopeContext, 59 | } from './updateExpression' 60 | 61 | type ResolveNodeFn = (props: ResolveNodeProps) => Promise 62 | type UpdateScopeContextFn = (props: UpdateScopeContextProps) => void 63 | 64 | export const nodeResolvers: Record = { 65 | [NodeType.ArrayExpression]: resolveArrayExpression as ResolveNodeFn, 66 | [NodeType.AssignmentExpression]: resolveAssignmentExpression as ResolveNodeFn, 67 | [NodeType.BinaryExpression]: resolveBinaryExpression as ResolveNodeFn, 68 | [NodeType.CallExpression]: resolveCallExpression as ResolveNodeFn, 69 | [NodeType.ChainExpression]: resolveChainExpression as ResolveNodeFn, 70 | [NodeType.ConditionalExpression]: 71 | resolveConditionalExpression as ResolveNodeFn, 72 | [NodeType.Identifier]: resolveIdentifier as ResolveNodeFn, 73 | [NodeType.Literal]: resolveLiteral as ResolveNodeFn, 74 | [NodeType.LogicalExpression]: resolveBinaryExpression as ResolveNodeFn, 75 | [NodeType.ObjectExpression]: resolveObjectExpression as ResolveNodeFn, 76 | [NodeType.MemberExpression]: resolveMemberExpression as ResolveNodeFn, 77 | [NodeType.SequenceExpression]: resolveSequenceExpression as ResolveNodeFn, 78 | [NodeType.UnaryExpression]: resolveUnaryExpression as ResolveNodeFn, 79 | [NodeType.UpdateExpression]: resolveUpdateExpression as ResolveNodeFn, 80 | } 81 | 82 | export const updateScopeContextResolvers: Record< 83 | NodeType, 84 | UpdateScopeContextFn 85 | > = { 86 | [NodeType.ArrayExpression]: updateArrayScopeContext as UpdateScopeContextFn, 87 | [NodeType.AssignmentExpression]: 88 | updateAssignmentScopeContext as UpdateScopeContextFn, 89 | [NodeType.BinaryExpression]: updateBinaryScopeContext as UpdateScopeContextFn, 90 | [NodeType.CallExpression]: updateCallScopeContext as UpdateScopeContextFn, 91 | [NodeType.ChainExpression]: updateChainScopeContext as UpdateScopeContextFn, 92 | [NodeType.ConditionalExpression]: 93 | updateConditionalScopeContext as UpdateScopeContextFn, 94 | [NodeType.Identifier]: updateIdentifierScopeContext as UpdateScopeContextFn, 95 | [NodeType.Literal]: updateLiteralScopeContext as UpdateScopeContextFn, 96 | [NodeType.LogicalExpression]: 97 | updateBinaryScopeContext as UpdateScopeContextFn, 98 | [NodeType.ObjectExpression]: updateObjectScopeContext as UpdateScopeContextFn, 99 | [NodeType.MemberExpression]: updateMemberScopeContext as UpdateScopeContextFn, 100 | [NodeType.SequenceExpression]: 101 | updateSequenceScopeContext as UpdateScopeContextFn, 102 | [NodeType.UnaryExpression]: updateUnaryScopeContext as UpdateScopeContextFn, 103 | [NodeType.UpdateExpression]: updateUpdateScopeContext as UpdateScopeContextFn, 104 | } 105 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/literal.ts: -------------------------------------------------------------------------------- 1 | import { type ResolveNodeProps } from '$promptl/compiler/logic/types' 2 | import { type Literal } from 'estree' 3 | 4 | /** 5 | * ### Literal 6 | * Represents a literal value. 7 | */ 8 | export async function resolve({ node }: ResolveNodeProps) { 9 | return node.value 10 | } 11 | 12 | export function updateScopeContext() { 13 | // Do nothing 14 | } 15 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/memberExpression.ts: -------------------------------------------------------------------------------- 1 | import { MEMBER_EXPRESSION_METHOD } from '$promptl/compiler/logic/operators' 2 | import type { 3 | ResolveNodeProps, 4 | UpdateScopeContextProps, 5 | } from '$promptl/compiler/logic/types' 6 | import type { Identifier, MemberExpression } from 'estree' 7 | 8 | import { resolveLogicNode, updateScopeContextForNode } from '..' 9 | 10 | /** 11 | * ### MemberExpression 12 | * Represents a property from an object. If the property does not exist in the object, it will return undefined. 13 | */ 14 | export async function resolve({ 15 | node, 16 | ...props 17 | }: ResolveNodeProps) { 18 | const object = await resolveLogicNode({ 19 | node: node.object, 20 | ...props, 21 | }) 22 | 23 | // Accessing to the property can be optional (?.) 24 | if (object == null && node.optional) return undefined 25 | 26 | const property = node.computed 27 | ? await resolveLogicNode({ 28 | node: node.property, 29 | ...props, 30 | }) 31 | : (node.property as Identifier).name 32 | 33 | return MEMBER_EXPRESSION_METHOD(object, property) 34 | } 35 | 36 | export function updateScopeContext({ 37 | node, 38 | ...props 39 | }: UpdateScopeContextProps) { 40 | updateScopeContextForNode({ node: node.object, ...props }) 41 | if (node.computed) { 42 | updateScopeContextForNode({ node: node.property, ...props }) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/objectExpression.ts: -------------------------------------------------------------------------------- 1 | import { 2 | resolveLogicNode, 3 | updateScopeContextForNode, 4 | } from '$promptl/compiler/logic' 5 | import { 6 | UpdateScopeContextProps, 7 | type ResolveNodeProps, 8 | } from '$promptl/compiler/logic/types' 9 | import errors from '$promptl/error/errors' 10 | import { type Identifier, type ObjectExpression } from 'estree' 11 | 12 | /** 13 | * ### ObjectExpression 14 | * Represents a javascript Object 15 | */ 16 | export async function resolve({ 17 | node, 18 | scope, 19 | raiseError, 20 | ...props 21 | }: ResolveNodeProps) { 22 | const resolvedObject: { [key: string]: any } = {} 23 | for (const prop of node.properties) { 24 | if (prop.type === 'SpreadElement') { 25 | const spreadObject = await resolveLogicNode({ 26 | node: prop.argument, 27 | scope, 28 | raiseError, 29 | ...props, 30 | }) 31 | if (typeof spreadObject !== 'object') { 32 | raiseError(errors.invalidSpreadInObject(typeof spreadObject), prop) 33 | } 34 | Object.entries(spreadObject as object).forEach(([key, value]) => { 35 | resolvedObject[key] = value 36 | }) 37 | continue 38 | } 39 | if (prop.type === 'Property') { 40 | const key = prop.key as Identifier 41 | const value = await resolveLogicNode({ 42 | node: prop.value, 43 | scope, 44 | raiseError, 45 | ...props, 46 | }) 47 | resolvedObject[key.name] = value 48 | continue 49 | } 50 | throw raiseError(errors.invalidObjectKey, prop) 51 | } 52 | return resolvedObject 53 | } 54 | 55 | export function updateScopeContext({ 56 | node, 57 | ...props 58 | }: UpdateScopeContextProps) { 59 | for (const prop of node.properties) { 60 | if (prop.type === 'SpreadElement') { 61 | updateScopeContextForNode({ node: prop.argument, ...props }) 62 | continue 63 | } 64 | if (prop.type === 'Property') { 65 | updateScopeContextForNode({ node: prop.value, ...props }) 66 | continue 67 | } 68 | props.raiseError(errors.invalidObjectKey, prop) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/sequenceExpression.ts: -------------------------------------------------------------------------------- 1 | import { 2 | resolveLogicNode, 3 | updateScopeContextForNode, 4 | } from '$promptl/compiler/logic' 5 | import type { 6 | ResolveNodeProps, 7 | UpdateScopeContextProps, 8 | } from '$promptl/compiler/logic/types' 9 | import type { SequenceExpression } from 'estree' 10 | 11 | /** 12 | * ### SequenceExpression 13 | * Represents a sequence of expressions. It is only used to evaluate ?. operators. 14 | */ 15 | export async function resolve({ 16 | node, 17 | ...props 18 | }: ResolveNodeProps) { 19 | return await Promise.all( 20 | node.expressions.map((expression) => 21 | resolveLogicNode({ node: expression, ...props }), 22 | ), 23 | ) 24 | } 25 | 26 | export function updateScopeContext({ 27 | node, 28 | ...props 29 | }: UpdateScopeContextProps) { 30 | for (const expression of node.expressions) { 31 | updateScopeContextForNode({ node: expression, ...props }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/unaryExpression.ts: -------------------------------------------------------------------------------- 1 | import { UNARY_OPERATOR_METHODS } from '$promptl/compiler/logic/operators' 2 | import type { 3 | ResolveNodeProps, 4 | UpdateScopeContextProps, 5 | } from '$promptl/compiler/logic/types' 6 | import errors from '$promptl/error/errors' 7 | import type { UnaryExpression } from 'estree' 8 | 9 | import { resolveLogicNode, updateScopeContextForNode } from '..' 10 | 11 | /** 12 | * ### UnaryExpression 13 | * Represents a simple operation on a single operand, either as a prefix or suffix. 14 | * 15 | * Example: `{!a}` 16 | */ 17 | export async function resolve({ 18 | node, 19 | raiseError, 20 | ...props 21 | }: ResolveNodeProps) { 22 | const unaryOperator = node.operator 23 | if (!(unaryOperator in UNARY_OPERATOR_METHODS)) { 24 | raiseError(errors.unsupportedOperator(unaryOperator), node) 25 | } 26 | 27 | const unaryArgument = await resolveLogicNode({ 28 | node: node.argument, 29 | raiseError, 30 | ...props, 31 | }) 32 | const unaryPrefix = node.prefix 33 | return UNARY_OPERATOR_METHODS[unaryOperator]?.(unaryArgument, unaryPrefix) 34 | } 35 | 36 | export function updateScopeContext({ 37 | node, 38 | scopeContext, 39 | ...props 40 | }: UpdateScopeContextProps) { 41 | const unaryOperator = node.operator 42 | if (!(unaryOperator in UNARY_OPERATOR_METHODS)) { 43 | props.raiseError(errors.unsupportedOperator(unaryOperator), node) 44 | } 45 | 46 | updateScopeContextForNode({ node: node.argument, scopeContext, ...props }) 47 | } 48 | -------------------------------------------------------------------------------- /src/compiler/logic/nodes/updateExpression.ts: -------------------------------------------------------------------------------- 1 | import { 2 | resolveLogicNode, 3 | updateScopeContextForNode, 4 | } from '$promptl/compiler/logic' 5 | import type { 6 | ResolveNodeProps, 7 | UpdateScopeContextProps, 8 | } from '$promptl/compiler/logic/types' 9 | import errors from '$promptl/error/errors' 10 | import type { AssignmentExpression, UpdateExpression } from 'estree' 11 | 12 | /** 13 | * ### UpdateExpression 14 | * Represents a javascript update expression. 15 | * Depending on the operator, it can increment or decrement a value. 16 | * Depending on the position of the operator, the return value can be resolved before or after the operation. 17 | * 18 | * Examples: `{--foo}` `{bar++}` 19 | */ 20 | export async function resolve({ 21 | node, 22 | scope, 23 | raiseError, 24 | ...props 25 | }: ResolveNodeProps) { 26 | const updateOperator = node.operator 27 | 28 | if (!['++', '--'].includes(updateOperator)) { 29 | raiseError(errors.unsupportedOperator(updateOperator), node) 30 | } 31 | 32 | const assignmentOperators = { 33 | '++': '+=', 34 | '--': '-=', 35 | } 36 | 37 | const originalValue = await resolveLogicNode({ 38 | node: node.argument, 39 | scope, 40 | raiseError, 41 | ...props, 42 | }) 43 | 44 | if (typeof originalValue !== 'number') { 45 | raiseError(errors.invalidUpdate(updateOperator, typeof originalValue), node) 46 | } 47 | 48 | // Simulate an AssignmentExpression with the same operation 49 | const assignmentNode = { 50 | ...node, 51 | type: 'AssignmentExpression', 52 | left: node.argument, 53 | operator: assignmentOperators[updateOperator], 54 | right: { 55 | type: 'Literal', 56 | value: 1, 57 | }, 58 | } as AssignmentExpression 59 | 60 | // Perform the assignment 61 | await resolveLogicNode({ 62 | node: assignmentNode, 63 | scope, 64 | raiseError, 65 | ...props, 66 | }) 67 | 68 | const updatedValue = await resolveLogicNode({ 69 | node: node.argument, 70 | scope, 71 | raiseError, 72 | ...props, 73 | }) 74 | 75 | return node.prefix ? updatedValue : originalValue 76 | } 77 | 78 | export function updateScopeContext({ 79 | node, 80 | ...props 81 | }: UpdateScopeContextProps) { 82 | const updateOperator = node.operator 83 | if (!['++', '--'].includes(updateOperator)) { 84 | props.raiseError(errors.unsupportedOperator(updateOperator), node) 85 | } 86 | 87 | updateScopeContextForNode({ node: node.argument, ...props }) 88 | } 89 | -------------------------------------------------------------------------------- /src/compiler/logic/operators.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/estree/estree/blob/master/es5.md#binary-operations 2 | export const BINARY_OPERATOR_METHODS: { 3 | [operator: string]: (left: any, right: any) => unknown 4 | } = { 5 | // BinaryExpression 6 | '==': (left, right) => left == right, 7 | '!=': (left, right) => left != right, 8 | '===': (left, right) => left === right, 9 | '!==': (left, right) => left !== right, 10 | '<': (left, right) => left < right, 11 | '<=': (left, right) => left <= right, 12 | '>': (left, right) => left > right, 13 | '>=': (left, right) => left >= right, 14 | '<<': (left, right) => left << right, 15 | '>>': (left, right) => left >> right, 16 | '>>>': (left, right) => left >>> right, 17 | '+': (left, right) => left + right, 18 | '-': (left, right) => left - right, 19 | '*': (left, right) => left * right, 20 | '/': (left, right) => left / right, 21 | '%': (left, right) => left % right, 22 | '|': (left, right) => left | right, 23 | '^': (left, right) => left ^ right, 24 | '&': (left, right) => left & right, 25 | in: (left, right) => left in right, 26 | instanceof: (left, right) => (left as object) instanceof right, 27 | 28 | // LogicalExpression 29 | '||': (left, right) => left || right, 30 | '&&': (left, right) => left && right, 31 | '??': (left, right) => left ?? right, 32 | } 33 | 34 | // https://github.com/estree/estree/blob/master/es5.md#unary-operations 35 | export const UNARY_OPERATOR_METHODS: { 36 | [operator: string]: (value: any, prefix: any) => unknown 37 | } = { 38 | // UnaryExpression 39 | '-': (value, prefix) => (prefix ? -value : value), 40 | '+': (value, prefix) => (prefix ? +value : value), 41 | '!': (value, _) => !value, 42 | '~': (value, _) => ~value, 43 | typeof: (value, _) => typeof value, 44 | void: (value, _) => void value, 45 | } 46 | 47 | // https://github.com/estree/estree/blob/master/es5.md#memberexpression 48 | export const MEMBER_EXPRESSION_METHOD = ( 49 | object: any, 50 | property: any, 51 | ): unknown => { 52 | const value = object[property] 53 | return typeof value === 'function' ? value.bind(object) : value 54 | } 55 | 56 | // https://github.com/estree/estree/blob/master/es5.md#assignmentexpression 57 | export const ASSIGNMENT_OPERATOR_METHODS: { 58 | [operator: string]: (left: any, right: any) => unknown 59 | } = { 60 | '=': (_, right) => right, 61 | '+=': (left, right) => left + right, 62 | '-=': (left, right) => left - right, 63 | '*=': (left, right) => left * right, 64 | '/=': (left, right) => left / right, 65 | '%=': (left, right) => left % right, 66 | '<<=': (left, right) => left << right, 67 | '>>=': (left, right) => left >> right, 68 | '>>>=': (left, right) => left >>> right, 69 | '|=': (left, right) => left | right, 70 | '^=': (left, right) => left ^ right, 71 | '&=': (left, right) => left & right, 72 | } 73 | -------------------------------------------------------------------------------- /src/compiler/logic/types.ts: -------------------------------------------------------------------------------- 1 | import Scope, { ScopeContext } from '$promptl/compiler/scope' 2 | import { Node } from 'estree' 3 | 4 | export enum NodeType { 5 | Literal = 'Literal', 6 | Identifier = 'Identifier', 7 | ObjectExpression = 'ObjectExpression', 8 | ArrayExpression = 'ArrayExpression', 9 | SequenceExpression = 'SequenceExpression', 10 | LogicalExpression = 'LogicalExpression', 11 | BinaryExpression = 'BinaryExpression', 12 | UnaryExpression = 'UnaryExpression', 13 | AssignmentExpression = 'AssignmentExpression', 14 | UpdateExpression = 'UpdateExpression', 15 | MemberExpression = 'MemberExpression', 16 | ConditionalExpression = 'ConditionalExpression', 17 | CallExpression = 'CallExpression', 18 | ChainExpression = 'ChainExpression', 19 | } 20 | 21 | type RaiseErrorFn = ( 22 | { code, message }: { code: string; message: string }, 23 | node: Node, 24 | ) => T 25 | 26 | export type ResolveNodeProps = { 27 | node: N 28 | scope: Scope 29 | raiseError: RaiseErrorFn 30 | } 31 | 32 | export type UpdateScopeContextProps = { 33 | node: N 34 | scopeContext: ScopeContext 35 | raiseError: RaiseErrorFn 36 | } 37 | -------------------------------------------------------------------------------- /src/compiler/scope.ts: -------------------------------------------------------------------------------- 1 | export type ScopePointers = { [key: string]: number } 2 | export type ScopeStash = unknown[] 3 | 4 | export default class Scope { 5 | /** 6 | * Global stash 7 | * All variable values are stored in a single global array. This is done to allow multiple 8 | * scopes to share the same variable values and be able to modify them. 9 | * 10 | * For example: 11 | * ```md 12 | * {var1 = 1} 13 | * {#if } 14 | * {var1 = 2} 15 | * {var2 = 3} 16 | * {/if} 17 | * ``` 18 | * In this case, there are two scopes: root and if. Both scopes share the same variable `var1`, 19 | * and modifying it in the if scope should also modify it in the root scope. But `var2` is only 20 | * defined in the if scope and should not be accessible in the root scope. 21 | * 22 | * Local pointers 23 | * Every scope has its own local pointers that contains the indexes of the variables in the global stash. 24 | */ 25 | 26 | // Stash of every variable value in the global scope 27 | private globalStash: ScopeStash = [] 28 | // Index of every variable in the stash in the current scope 29 | private localPointers: ScopePointers = {} 30 | 31 | constructor(initialState: Record = {}) { 32 | for (const [key, value] of Object.entries(initialState)) { 33 | this.localPointers[key] = this.addToStash(value) 34 | } 35 | } 36 | 37 | static withStash(stash: ScopeStash): Scope { 38 | const scope = new Scope() 39 | scope.globalStash = stash 40 | return scope 41 | } 42 | 43 | exists(name: string): boolean { 44 | return name in this.localPointers 45 | } 46 | 47 | get(name: string): unknown { 48 | const index = this.localPointers[name] ?? undefined 49 | 50 | if (index === undefined) { 51 | throw new Error(`Variable '${name}' does not exist`) 52 | } 53 | 54 | return this.readFromStash(index) 55 | } 56 | 57 | set(name: string, value: unknown): void { 58 | if (!this.exists(name)) { 59 | this.localPointers[name] = this.addToStash(value) 60 | return 61 | } 62 | const index = this.localPointers[name]! 63 | this.modifyStash(index, value) 64 | } 65 | 66 | copy(localPointers?: ScopePointers): Scope { 67 | const scope = new Scope() 68 | scope.globalStash = this.globalStash 69 | scope.localPointers = { ...(localPointers ?? this.localPointers) } 70 | return scope 71 | } 72 | 73 | getStash(): ScopeStash { 74 | return this.globalStash 75 | } 76 | 77 | getPointers(): ScopePointers { 78 | return this.localPointers 79 | } 80 | 81 | setPointers(pointers: ScopePointers): void { 82 | this.localPointers = pointers 83 | } 84 | 85 | setStash(stash: ScopeStash): void { 86 | this.globalStash = stash 87 | } 88 | 89 | serialize(): { stash: ScopeStash; pointers: ScopePointers } { 90 | return { 91 | stash: this.globalStash, 92 | pointers: this.localPointers, 93 | } 94 | } 95 | 96 | private readFromStash(index: number): unknown { 97 | return this.globalStash[index] 98 | } 99 | 100 | private addToStash(value: unknown): number { 101 | this.globalStash.push(value) 102 | return this.globalStash.length - 1 103 | } 104 | 105 | private modifyStash(index: number, value: unknown): void { 106 | this.globalStash[index] = value 107 | } 108 | } 109 | 110 | export type ScopeContext = { 111 | // If defined, all usedUndefinedVariables that are not in this set will return an error 112 | onlyPredefinedVariables?: Set 113 | // Variables that are not in current scope but have been used 114 | usedUndefinedVariables: Set 115 | // Variables that are in current scope 116 | definedVariables: Set 117 | } 118 | -------------------------------------------------------------------------------- /src/compiler/test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AssistantMessage, 3 | Config, 4 | ContentType, 5 | Conversation, 6 | Message, 7 | MessageContent, 8 | } from '$promptl/types' 9 | import { expect } from 'vitest' 10 | 11 | import { Chain } from '../chain' 12 | 13 | export async function getExpectedError( 14 | action: () => Promise, 15 | errorClass: new () => T, 16 | ): Promise { 17 | try { 18 | await action() 19 | } catch (err) { 20 | expect(err).toBeInstanceOf(errorClass) 21 | return err as T 22 | } 23 | throw new Error('Expected an error to be thrown') 24 | } 25 | 26 | export async function complete({ 27 | chain, 28 | callback, 29 | maxSteps = 50, 30 | }: { 31 | chain: Chain 32 | callback?: (convo: Conversation) => Promise 33 | maxSteps?: number 34 | }): Promise<{ 35 | response: MessageContent[] 36 | messages: Message[] 37 | config: Config 38 | steps: number 39 | }> { 40 | let steps = 0 41 | let responseMessage: Omit | undefined 42 | 43 | while (true) { 44 | const { completed, messages, config } = await chain.step(responseMessage) 45 | 46 | if (completed) { 47 | return { 48 | messages, 49 | config, 50 | steps, 51 | response: responseMessage!.content as MessageContent[], 52 | } 53 | } 54 | 55 | const response = callback 56 | ? await callback({ messages, config }) 57 | : 'RESPONSE' 58 | responseMessage = { content: [{ type: ContentType.text, text: response }] } 59 | steps++ 60 | 61 | if (steps > maxSteps) throw new Error('too many chain steps') 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/compiler/types.ts: -------------------------------------------------------------------------------- 1 | import { TemplateNode } from '$promptl/parser/interfaces' 2 | import { MessageRole } from '$promptl/types' 3 | 4 | import type Scope from './scope' 5 | 6 | export type Document = { 7 | path: string 8 | content: string 9 | } 10 | export type ReferencePromptFn = ( 11 | path: string, 12 | from?: string, 13 | ) => Promise 14 | 15 | export type ResolveBaseNodeProps = { 16 | node: N 17 | scope: Scope 18 | isInsideStepTag: boolean 19 | isInsideMessageTag: boolean 20 | isInsideContentTag: boolean 21 | completedValue?: unknown 22 | fullPath?: string | undefined 23 | } 24 | 25 | export type CompileOptions = { 26 | referenceFn?: ReferencePromptFn 27 | fullPath?: string 28 | defaultRole?: MessageRole 29 | includeSourceMap?: boolean 30 | } 31 | -------------------------------------------------------------------------------- /src/compiler/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { ZodError, ZodIssue, ZodIssueCode, z } from 'zod' 3 | import { getMostSpecificError } from './utils' 4 | 5 | function makeZodError(issues: ZodIssue[]): ZodError { 6 | // @ts-ignore 7 | return new ZodError(issues) 8 | } 9 | 10 | describe('getMostSpecificError', () => { 11 | it('returns the message and path for a simple error', () => { 12 | const error = makeZodError([ 13 | { 14 | code: ZodIssueCode.invalid_type, 15 | expected: 'string', 16 | received: 'number', 17 | path: ['foo'], 18 | message: 'Expected string', 19 | }, 20 | ]) 21 | const result = getMostSpecificError(error.issues[0]!) 22 | expect(result.message).toMatch('Expected type') 23 | expect(result.path).toEqual(['foo']) 24 | }) 25 | 26 | it('returns the most specific (deepest) error in a nested structure', () => { 27 | const unionError = makeZodError([ 28 | { 29 | code: ZodIssueCode.invalid_union, 30 | unionErrors: [ 31 | makeZodError([ 32 | { 33 | code: ZodIssueCode.invalid_type, 34 | expected: 'string', 35 | received: 'number', 36 | path: ['foo', 'bar'], 37 | message: 'Expected string', 38 | }, 39 | ]), 40 | makeZodError([ 41 | { 42 | code: ZodIssueCode.invalid_type, 43 | expected: 'number', 44 | received: 'string', 45 | path: ['foo'], 46 | message: 'Expected number', 47 | }, 48 | ]), 49 | ], 50 | path: ['foo'], 51 | message: 'Invalid union', 52 | }, 53 | ]) 54 | const result = getMostSpecificError(unionError.issues[0]!) 55 | expect(result.path).toEqual(['foo', 'bar']) 56 | expect(result.message).toMatch('Expected type') 57 | }) 58 | 59 | it('returns the error message and empty path if no issues', () => { 60 | const error = makeZodError([ 61 | { 62 | code: ZodIssueCode.custom, 63 | path: [], 64 | message: 'Custom error', 65 | }, 66 | ]) 67 | const result = getMostSpecificError(error.issues[0]!) 68 | expect(result.message).toMatch('Custom error') 69 | expect(result.path).toEqual([]) 70 | }) 71 | 72 | it('handles errors with multiple paths and picks the deepest', () => { 73 | const error = makeZodError([ 74 | { 75 | code: ZodIssueCode.invalid_type, 76 | expected: 'string', 77 | received: 'number', 78 | path: ['a'], 79 | message: 'Expected string', 80 | }, 81 | { 82 | code: ZodIssueCode.invalid_type, 83 | expected: 'number', 84 | received: 'string', 85 | path: ['a', 'b', 'c'], 86 | message: 'Expected number', 87 | }, 88 | ]) 89 | const result = getMostSpecificError(error.issues[1]!) // The deepest path is at index 1 90 | expect(result.path).toEqual(['a', 'b', 'c']) 91 | expect(result.message).toMatch('Expected type') 92 | }) 93 | 94 | it('handles ZodError thrown by zod schema', () => { 95 | const schema = z.object({ foo: z.string() }) 96 | let error: ZodError | undefined 97 | try { 98 | schema.parse({ foo: 123 }) 99 | } catch (e) { 100 | error = e as ZodError 101 | } 102 | expect(error).toBeDefined() 103 | expect(error!.issues.length).toBeGreaterThan(0) 104 | const result = getMostSpecificError(error!.issues[0]!) 105 | expect(result.path).toEqual(['foo']) 106 | expect(result.message).toMatch('Expected type') 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { ContentTypeTagName, MessageRole } from './types' 2 | 3 | export const CUSTOM_TAG_START = '{{' 4 | export const CUSTOM_TAG_END = '}}' 5 | 6 | export enum TAG_NAMES { 7 | message = 'message', 8 | system = MessageRole.system, 9 | user = MessageRole.user, 10 | assistant = MessageRole.assistant, 11 | tool = MessageRole.tool, 12 | content = 'content', 13 | text = ContentTypeTagName.text, 14 | image = ContentTypeTagName.image, 15 | file = ContentTypeTagName.file, 16 | toolCall = ContentTypeTagName.toolCall, 17 | prompt = 'prompt', 18 | scope = 'scope', 19 | step = 'step', 20 | } 21 | 22 | export const CUSTOM_MESSAGE_ROLE_ATTR = 'role' as const 23 | export const CUSTOM_CONTENT_TYPE_ATTR = 'type' as const 24 | export const REFERENCE_PROMPT_ATTR = 'path' as const 25 | export const REFERENCE_DEPTH_LIMIT = 50 26 | export const CHAIN_STEP_ISOLATED_ATTR = 'isolated' as const 27 | 28 | export enum KEYWORDS { 29 | if = 'if', 30 | endif = 'endif', 31 | else = 'else', 32 | for = 'for', 33 | endfor = 'endfor', 34 | as = 'as', 35 | in = 'in', 36 | true = 'true', 37 | false = 'false', 38 | null = 'null', 39 | } 40 | 41 | export const RESERVED_KEYWORDS = Object.values(KEYWORDS) 42 | export const RESERVED_TAGS = Object.values(TAG_NAMES) 43 | -------------------------------------------------------------------------------- /src/error/error.ts: -------------------------------------------------------------------------------- 1 | import { Fragment } from '$promptl/parser/interfaces' 2 | import { locate } from 'locate-character' 3 | 4 | export interface Position { 5 | line: number 6 | column: number 7 | } 8 | 9 | type CompileErrorProps = { 10 | name: string 11 | code: string 12 | source: string 13 | start: number 14 | end?: number 15 | fragment?: Fragment 16 | } 17 | 18 | export default class CompileError extends Error { 19 | code?: string 20 | start?: Position 21 | end?: Position 22 | pos?: number 23 | frame?: string 24 | fragment?: Fragment 25 | 26 | toString() { 27 | if (!this.start) return this.message 28 | return `${this.message} (${this.start.line}:${this.start.column})\n${this.frame}` 29 | } 30 | } 31 | 32 | function tabsToSpaces(str: string) { 33 | return str.replace(/^\t+/, (match) => match.split('\t').join(' ')) 34 | } 35 | 36 | function getCodeFrame( 37 | source: string, 38 | line: number, 39 | startColumn: number, 40 | endColumn: number | undefined, 41 | ): string { 42 | const lines = source.split('\n') 43 | const frameStart = Math.max(0, line - 2) 44 | const frameEnd = Math.min(line + 3, lines.length) 45 | const digits = String(frameEnd + 1).length 46 | return lines 47 | .slice(frameStart, frameEnd) 48 | .map((str, i) => { 49 | const isErrorLine = frameStart + i === line 50 | const lineNum = String(i + frameStart + 1).padStart(digits, ' ') 51 | if (isErrorLine) { 52 | const indicator = 53 | ' '.repeat( 54 | digits + 2 + tabsToSpaces(str.slice(0, startColumn)).length, 55 | ) + 56 | '^' + 57 | '~'.repeat(endColumn ? Math.max(0, endColumn - startColumn - 1) : 0) 58 | return `${lineNum}: ${tabsToSpaces(str)}\n\n${indicator}` 59 | } 60 | return `${lineNum}: ${tabsToSpaces(str)}` 61 | }) 62 | .join('\n') 63 | } 64 | 65 | export function error(message: string, props: CompileErrorProps): never { 66 | const error = new CompileError(message) 67 | error.name = props.name 68 | const start = locate(props.source, props.start, { 69 | offsetLine: 1, 70 | offsetColumn: 1, 71 | }) 72 | const end = locate(props.source, props.end ?? props.start, { 73 | offsetLine: 1, 74 | offsetColumn: 1, 75 | }) 76 | error.code = props.code 77 | error.start = start 78 | error.end = end 79 | error.pos = props.start 80 | error.frame = getCodeFrame( 81 | props.source, 82 | (start?.line ?? 1) - 1, 83 | start?.column ?? 0, 84 | end?.column, 85 | ) 86 | error.fragment = props.fragment 87 | throw error 88 | } 89 | -------------------------------------------------------------------------------- /src/index.rpc.ts: -------------------------------------------------------------------------------- 1 | import { serve } from './rpc' 2 | 3 | serve() 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | export * from './compiler' 3 | export * from './parser' 4 | export * from './providers' 5 | 6 | export { type Fragment } from './parser/interfaces' 7 | 8 | export { default as CompileError } from './error/error' 9 | -------------------------------------------------------------------------------- /src/parser/index.ts: -------------------------------------------------------------------------------- 1 | import CompileError, { error } from '$promptl/error/error' 2 | import PARSER_ERRORS from '$promptl/error/errors' 3 | import { reserved } from '$promptl/utils/names' 4 | import { isIdentifierChar, isIdentifierStart } from 'acorn' 5 | 6 | import type { BaseNode, Fragment } from './interfaces' 7 | import fragment from './state/fragment' 8 | import fullCharCodeAt from './utils/full_char_code_at' 9 | 10 | export function parse(template: string) { 11 | return new Parser(template).parse() 12 | } 13 | 14 | type ParserState = (parser: Parser) => void | ParserState 15 | type AutoClosedTag = { tag: string; reason: string; depth: number } 16 | 17 | export class Parser { 18 | index: number = 0 19 | stack: BaseNode[] = [] 20 | lastAutoClosedTag: AutoClosedTag | null = null 21 | fragment: Fragment 22 | 23 | constructor(public template: string) { 24 | this.fragment = { 25 | start: 0, 26 | end: this.template.length, 27 | type: 'Fragment', 28 | children: [], 29 | } 30 | } 31 | 32 | parse(): Fragment { 33 | try { 34 | return this._parse() 35 | } catch (err) { 36 | if (err instanceof CompileError) { 37 | throw err 38 | } 39 | this.error({ 40 | code: 'parse-error', 41 | message: 'Syntax error', 42 | }) 43 | } 44 | } 45 | 46 | _parse(): Fragment { 47 | this.stack.push(this.fragment) 48 | 49 | let state: ParserState = fragment 50 | while (this.index < this.template.length) { 51 | state = state(this) || fragment 52 | } 53 | if (this.stack.length > 1) { 54 | const current = this.current() 55 | this.error( 56 | { 57 | code: `unclosed-block`, 58 | message: `Block was left open`, 59 | }, 60 | current.start! + 1, 61 | ) 62 | } 63 | if (state !== fragment) { 64 | this.error({ 65 | code: `unexpected-eof`, 66 | message: `Unexpected end of input`, 67 | }) 68 | } 69 | if (this.fragment.children.length) { 70 | let start = this.fragment.children[0]!.start! 71 | while (/\s/.test(this.fragment[start])) start += 1 72 | let end = this.fragment.children[this.fragment.children.length - 1]!.end! 73 | while (/\s/.test(this.fragment[end - 1])) end -= 1 74 | this.fragment.start = start 75 | this.fragment.end = end 76 | } else { 77 | this.fragment.start = this.fragment.end = null 78 | } 79 | 80 | return this.fragment 81 | } 82 | 83 | current(): BaseNode { 84 | return this.stack[this.stack.length - 1]! 85 | } 86 | 87 | match(str: string) { 88 | return this.template.slice(this.index, this.index + str.length) === str 89 | } 90 | 91 | allowWhitespace() { 92 | while ( 93 | this.index < this.template.length && 94 | /\s/.test(this.template[this.index] || '') 95 | ) { 96 | this.index++ 97 | } 98 | } 99 | 100 | requireWhitespace() { 101 | if (!/\s/.test(this.template[this.index]!)) { 102 | this.error({ 103 | code: 'missing-whitespace', 104 | message: 'Expected whitespace', 105 | }) 106 | } 107 | this.allowWhitespace() 108 | } 109 | 110 | eat( 111 | str: string, 112 | required: boolean = false, 113 | error?: { code: string; message: string }, 114 | ) { 115 | if (this.match(str)) { 116 | this.index += str.length 117 | return true 118 | } 119 | if (required) { 120 | this.error( 121 | error || 122 | (this.index === this.template.length 123 | ? PARSER_ERRORS.unexpectedEofToken(str) 124 | : PARSER_ERRORS.unexpectedToken(str)), 125 | ) 126 | } 127 | return false 128 | } 129 | 130 | error( 131 | { code, message }: { code: string; message: string }, 132 | index = this.index, 133 | ): never { 134 | error(message, { 135 | name: 'ParseError', 136 | code, 137 | source: this.template, 138 | start: index - 1, 139 | end: this.template.length, 140 | fragment: this.fragment, 141 | }) 142 | } 143 | 144 | acornError(err: CompileError) { 145 | this.error( 146 | { 147 | code: 'parse-error', 148 | message: err.message.replace(/ \(\d+:\d+\)$/, ''), 149 | }, 150 | err.pos, 151 | ) 152 | } 153 | 154 | matchRegex(pattern: RegExp) { 155 | const match = pattern.exec(this.template.slice(this.index)) 156 | if (!match || match.index !== 0) return null 157 | return match[0] 158 | } 159 | 160 | read(pattern: RegExp) { 161 | const result = this.matchRegex(pattern) 162 | if (result) this.index += result.length 163 | return result 164 | } 165 | 166 | readIdentifier(allowReserved: boolean = false) { 167 | const start = this.index 168 | let i = this.index 169 | const code = fullCharCodeAt(this.template, i) 170 | if (!isIdentifierStart(code, true)) return null 171 | i += code <= 0xffff ? 1 : 2 172 | while (i < this.template.length) { 173 | const code = fullCharCodeAt(this.template, i) 174 | if (!isIdentifierChar(code, true)) break 175 | i += code <= 0xffff ? 1 : 2 176 | } 177 | const identifier = this.template.slice(this.index, (this.index = i)) 178 | if (!allowReserved && reserved.has(identifier)) { 179 | this.error( 180 | { 181 | code: 'unexpected-reserved-word', 182 | message: `'${identifier}' is a reserved word in JavaScript and cannot be used here`, 183 | }, 184 | start, 185 | ) 186 | } 187 | return identifier 188 | } 189 | 190 | readUntil(pattern: RegExp) { 191 | if (this.index >= this.template.length) { 192 | this.error(PARSER_ERRORS.unexpectedEof) 193 | } 194 | const start = this.index 195 | const match = pattern.exec(this.template.slice(this.index)) 196 | if (match) { 197 | this.index = start + match.index 198 | return this.template.slice(start, this.index) 199 | } 200 | this.index = this.template.length 201 | return this.template.slice(start) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/parser/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { TAG_NAMES } from '$promptl/constants' 2 | import { ContentTypeTagName, MessageRole } from '$promptl/types' 3 | import { Identifier, type Node as LogicalExpression } from 'estree' 4 | 5 | export type BaseNode = { 6 | start: number | null 7 | end: number | null 8 | type: string 9 | children?: TemplateNode[] 10 | [propName: string]: any 11 | } 12 | 13 | export type Fragment = BaseNode & { 14 | type: 'Fragment' 15 | children: TemplateNode[] 16 | } 17 | 18 | export type Config = BaseNode & { 19 | type: 'Config' 20 | value: string 21 | } 22 | 23 | export type Text = BaseNode & { 24 | type: 'Text' 25 | data: string 26 | } 27 | 28 | export type Attribute = BaseNode & { 29 | type: 'Attribute' 30 | name: string 31 | value: TemplateNode[] | true 32 | } 33 | 34 | type IElementTag = BaseNode & { 35 | type: 'ElementTag' 36 | name: T 37 | attributes: Attribute[] 38 | children: TemplateNode[] 39 | } 40 | 41 | export type MessageTag = 42 | | IElementTag 43 | | IElementTag 44 | export type ContentTag = 45 | | IElementTag 46 | | IElementTag 47 | 48 | export type ReferenceTag = IElementTag 49 | export type ScopeTag = IElementTag 50 | export type ChainStepTag = IElementTag 51 | export type ElementTag = 52 | | ContentTag 53 | | MessageTag 54 | | ReferenceTag 55 | | ScopeTag 56 | | ChainStepTag 57 | | IElementTag 58 | 59 | export type MustacheTag = BaseNode & { 60 | type: 'MustacheTag' 61 | expression: LogicalExpression 62 | } 63 | 64 | export type Comment = BaseNode & { 65 | type: 'Comment' 66 | data: string 67 | } 68 | 69 | export type ElseBlock = BaseNode & { 70 | type: 'ElseBlock' 71 | } 72 | 73 | export type IfBlock = BaseNode & { 74 | type: 'IfBlock' 75 | expression: LogicalExpression 76 | else: ElseBlock | null 77 | } 78 | 79 | export type ForBlock = BaseNode & { 80 | type: 'ForBlock' 81 | expression: LogicalExpression 82 | context: Identifier 83 | index: Identifier | null 84 | else: ElseBlock | null 85 | } 86 | 87 | export type TemplateNode = 88 | | Fragment 89 | | Config 90 | | Text 91 | | ElementTag 92 | | MustacheTag 93 | | Comment 94 | | IfBlock 95 | | ForBlock 96 | -------------------------------------------------------------------------------- /src/parser/read/context.ts: -------------------------------------------------------------------------------- 1 | import type CompileError from '$promptl/error/error' 2 | import PARSER_ERRORS from '$promptl/error/errors' 3 | import { parseExpressionAt } from '$promptl/parser/utils/acorn' 4 | import { 5 | getBracketClose, 6 | isBracketClose, 7 | isBracketOpen, 8 | isBracketPair, 9 | } from '$promptl/parser/utils/bracket' 10 | import fullCharCodeAt from '$promptl/parser/utils/full_char_code_at' 11 | import { isIdentifierStart } from 'acorn' 12 | import { Pattern } from 'estree' 13 | 14 | import { Parser } from '..' 15 | 16 | export default function readContext( 17 | parser: Parser, 18 | ): Pattern & { start: number; end: number } { 19 | const start = parser.index 20 | let i = parser.index 21 | 22 | const code = fullCharCodeAt(parser.template, i) 23 | if (isIdentifierStart(code, true)) { 24 | return { 25 | type: 'Identifier', 26 | name: parser.readIdentifier()!, 27 | start, 28 | end: parser.index, 29 | } 30 | } 31 | 32 | if (!isBracketOpen(code)) { 33 | parser.error(PARSER_ERRORS.unexpectedTokenDestructure) 34 | } 35 | 36 | const bracketStack: number[] = [code] 37 | i += code <= 0xffff ? 1 : 2 38 | 39 | while (i < parser.template.length) { 40 | const code = fullCharCodeAt(parser.template, i) 41 | if (isBracketOpen(code)) { 42 | bracketStack.push(code) 43 | } else if (isBracketClose(code)) { 44 | if (!isBracketPair(bracketStack[bracketStack.length - 1]!, code)) { 45 | parser.error( 46 | PARSER_ERRORS.unexpectedToken( 47 | String.fromCharCode( 48 | getBracketClose(bracketStack[bracketStack.length - 1]!) ?? 0, 49 | ), 50 | ), 51 | ) 52 | } 53 | bracketStack.pop() 54 | if (bracketStack.length === 0) { 55 | i += code <= 0xffff ? 1 : 2 56 | break 57 | } 58 | } 59 | i += code <= 0xffff ? 1 : 2 60 | } 61 | 62 | parser.index = i 63 | 64 | const patternString = parser.template.slice(start, i) 65 | try { 66 | // the length of the `space_with_newline` has to be start - 1 67 | // because we added a `(` in front of the pattern_string, 68 | // which shifted the entire string to right by 1 69 | // so we offset it by removing 1 character in the `space_with_newline` 70 | // to achieve that, we remove the 1st space encountered, 71 | // so it will not affect the `column` of the node 72 | let spaceWithNewLine = parser.template 73 | .slice(0, start) 74 | .replace(/[^\n]/g, ' ') 75 | const firstSpace = spaceWithNewLine.indexOf(' ') 76 | spaceWithNewLine = 77 | spaceWithNewLine.slice(0, firstSpace) + 78 | spaceWithNewLine.slice(firstSpace + 1) 79 | 80 | return parseExpressionAt( 81 | `${spaceWithNewLine}(${patternString} = 1)`, 82 | start - 1, 83 | ).left 84 | } catch (error) { 85 | parser.acornError(error as CompileError) 86 | } 87 | 88 | return { 89 | type: 'Identifier', 90 | name: '', 91 | start: parser.index, 92 | end: parser.index, 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/parser/read/expression.ts: -------------------------------------------------------------------------------- 1 | import CompileError from '$promptl/error/error' 2 | import PARSER_ERRORS from '$promptl/error/errors' 3 | import { Parser } from '$promptl/parser' 4 | import { parseExpressionAt } from '$promptl/parser/utils/acorn' 5 | 6 | export default function readExpression(parser: Parser) { 7 | try { 8 | const node = parseExpressionAt(parser.template, parser.index) 9 | 10 | let numParenthesis = 0 11 | 12 | for (let i = parser.index; i < node.start; i += 1) { 13 | if (parser.template[i] === '(') numParenthesis += 1 14 | } 15 | 16 | let index = node.end 17 | while (numParenthesis > 0) { 18 | const char = parser.template[index] 19 | 20 | if (char === ')') { 21 | numParenthesis -= 1 22 | } else if (!/\s/.test(char!)) { 23 | parser.error(PARSER_ERRORS.unexpectedToken(')'), index) 24 | } 25 | 26 | index += 1 27 | } 28 | 29 | parser.index = index 30 | 31 | return node 32 | } catch (err) { 33 | parser.acornError(err as CompileError) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/parser/state/config.ts: -------------------------------------------------------------------------------- 1 | import PARSER_ERRORS from '$promptl/error/errors' 2 | import { Parser } from '$promptl/parser' 3 | import type { Config } from '$promptl/parser/interfaces' 4 | 5 | export function config(parser: Parser) { 6 | const start = parser.index 7 | parser.eat('---') 8 | 9 | // Read until there is a line break followed by a triple dash 10 | const currentIndex = parser.index 11 | const data = parser.readUntil(/\n\s*---\s*/) 12 | if (parser.index === parser.template.length) { 13 | parser.error(PARSER_ERRORS.unexpectedToken('---'), currentIndex + 1) 14 | } 15 | 16 | parser.allowWhitespace() 17 | parser.eat('---', true) 18 | parser.eat('\n') 19 | 20 | const node = { 21 | start, 22 | end: parser.index, 23 | type: 'Config', 24 | raw: data, 25 | value: data, 26 | } as Config 27 | 28 | parser.current().children!.push(node) 29 | } 30 | -------------------------------------------------------------------------------- /src/parser/state/fragment.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CUSTOM_TAG_END, 3 | CUSTOM_TAG_START, 4 | } from '$promptl/constants' 5 | 6 | import { Parser } from '..' 7 | import { RESERVED_TAG_REGEX } from '../utils/regex' 8 | import { config } from './config' 9 | import { multiLineComment } from './multi_line_comment' 10 | import { mustache } from './mustache' 11 | import { tag } from './tag' 12 | import { text } from './text' 13 | 14 | export default function fragment(parser: Parser): (parser: Parser) => void { 15 | if ( 16 | parser.matchRegex(RESERVED_TAG_REGEX) || 17 | parser.match('