@promptbook/remote-server
Version:
It's time for a paradigm shift. The future of software in plain English, French or Latin
1,618 lines (1,523 loc) โข 292 kB
JavaScript
import colors from 'colors';
import express from 'express';
import http from 'http';
import { Server } from 'socket.io';
import spaceTrim, { spaceTrim as spaceTrim$1 } from 'spacetrim';
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
import { forTime } from 'waitasecond';
import { randomBytes } from 'crypto';
import { spawn } from 'child_process';
import { stat, access, constants, readFile, writeFile, readdir, mkdir } from 'fs/promises';
import { join, basename, dirname } from 'path';
import { Subject } from 'rxjs';
import { format } from 'prettier';
import parserHtml from 'prettier/parser-html';
import hexEncoder from 'crypto-js/enc-hex';
import sha256 from 'crypto-js/sha256';
import { SHA256 } from 'crypto-js';
import { lookup, extension } from 'mime-types';
import { parse, unparse } from 'papaparse';
// โ ๏ธ WARNING: This code has been generated so that any manual changes will be overwritten
/**
* The version of the Book language
*
* @generated
* @see https://github.com/webgptorg/book
*/
const BOOK_LANGUAGE_VERSION = '1.0.0';
/**
* The version of the Promptbook engine
*
* @generated
* @see https://github.com/webgptorg/promptbook
*/
const PROMPTBOOK_ENGINE_VERSION = '0.89.0-16';
/**
* TODO: string_promptbook_version should be constrained to the all versions of Promptbook engine
* Note: [๐] Ignore a discrepancy between file name and entity name
*/
/**
* Error thrown when a fetch request fails
*
* @public exported from `@promptbook/core`
*/
class PromptbookFetchError extends Error {
constructor(message) {
super(message);
this.name = 'PromptbookFetchError';
Object.setPrototypeOf(this, PromptbookFetchError.prototype);
}
}
/**
* Returns the same value that is passed as argument.
* No side effects.
*
* Note: It can be usefull for:
*
* 1) Leveling indentation
* 2) Putting always-true or always-false conditions without getting eslint errors
*
* @param value any values
* @returns the same values
* @private within the repository
*/
function just(value) {
if (value === undefined) {
return undefined;
}
return value;
}
/**
* Name for the Promptbook
*
* TODO: [๐ฝ] Unite branding and make single place for it
*
* @public exported from `@promptbook/core`
*/
const NAME = `Promptbook`;
/**
* Email of the responsible person
*
* @public exported from `@promptbook/core`
*/
const ADMIN_EMAIL = 'pavol@ptbk.io';
/**
* Name of the responsible person for the Promptbook on GitHub
*
* @public exported from `@promptbook/core`
*/
const ADMIN_GITHUB_NAME = 'hejny';
/**
* Claim for the Promptbook
*
* TODO: [๐ฝ] Unite branding and make single place for it
*
* @public exported from `@promptbook/core`
*/
const CLAIM = `It's time for a paradigm shift. The future of software in plain English, French or Latin`;
// <- TODO: [๐] Pick the best claim
/**
* When the title is not provided, the default title is used
*
* @public exported from `@promptbook/core`
*/
const DEFAULT_BOOK_TITLE = `โจ Untitled Book`;
/**
* Maximum file size limit
*
* @public exported from `@promptbook/core`
*/
const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
// <- TODO: [๐ง ] Better system for generator warnings - not always "code" and "by `@promptbook/cli`"
/**
* The maximum number of iterations for a loops
*
* @private within the repository - too low-level in comparison with other `MAX_...`
*/
const LOOP_LIMIT = 1000;
/**
* Strings to represent various values in the context of parameter values
*
* @public exported from `@promptbook/utils`
*/
const VALUE_STRINGS = {
empty: '(nothing; empty string)',
null: '(no value; null)',
undefined: '(unknown value; undefined)',
nan: '(not a number; NaN)',
infinity: '(infinity; โ)',
negativeInfinity: '(negative infinity; -โ)',
unserializable: '(unserializable value)',
circular: '(circular JSON)',
};
/**
* Small number limit
*
* @public exported from `@promptbook/utils`
*/
const SMALL_NUMBER = 0.001;
/**
* Short time interval to prevent race conditions in milliseconds
*
* @private within the repository - too low-level in comparison with other `MAX_...`
*/
const IMMEDIATE_TIME = 10;
/**
* The maximum length of the (generated) filename
*
* @public exported from `@promptbook/core`
*/
const MAX_FILENAME_LENGTH = 30;
/**
* Strategy for caching the intermediate results for knowledge sources
*
* @public exported from `@promptbook/core`
*/
const DEFAULT_INTERMEDIATE_FILES_STRATEGY = 'HIDE_AND_KEEP';
// <- TODO: [๐ก] Change to 'VISIBLE'
/**
* The maximum number of (LLM) tasks running in parallel
*
* @public exported from `@promptbook/core`
*/
const DEFAULT_MAX_PARALLEL_COUNT = 5; // <- TODO: [๐คนโโ๏ธ]
/**
* The maximum number of attempts to execute LLM task before giving up
*
* @public exported from `@promptbook/core`
*/
const DEFAULT_MAX_EXECUTION_ATTEMPTS = 10; // <- TODO: [๐คนโโ๏ธ]
// <- TODO: [๐] Make also `BOOKS_DIRNAME_ALTERNATIVES`
// TODO: Just `.promptbook` in config, hardcode subfolders like `download-cache` or `execution-cache`
/**
* Where to store the temporary downloads
*
* Note: When the folder does not exist, it is created recursively
*
* @public exported from `@promptbook/core`
*/
const DEFAULT_DOWNLOAD_CACHE_DIRNAME = './.promptbook/download-cache';
/**
* Where to store the scrape cache
*
* Note: When the folder does not exist, it is created recursively
*
* @public exported from `@promptbook/core`
*/
const DEFAULT_SCRAPE_CACHE_DIRNAME = './.promptbook/scrape-cache';
// <- TODO: [๐งโโ๏ธ]
/**
* @@@
*
* @public exported from `@promptbook/core`
*/
const DEFAULT_CSV_SETTINGS = Object.freeze({
delimiter: ',',
quoteChar: '"',
newline: '\n',
skipEmptyLines: true,
});
/**
* @@@
*
* @public exported from `@promptbook/core`
*/
let DEFAULT_IS_VERBOSE = false;
/**
* @@@
*
* @public exported from `@promptbook/core`
*/
const DEFAULT_IS_AUTO_INSTALLED = false;
/**
* @@@
*
* @private within the repository
*/
const IS_PIPELINE_LOGIC_VALIDATED = just(
/**/
// Note: In normal situations, we check the pipeline logic:
true);
/**
* Note: [๐] Ignore a discrepancy between file name and entity name
* TODO: [๐ง ][๐งโโ๏ธ] Maybe join remoteServerUrl and path into single value
*/
/**
* Make error report URL for the given error
*
* @private private within the repository
*/
function getErrorReportUrl(error) {
const report = {
title: `๐ Error report from ${NAME}`,
body: spaceTrim((block) => `
\`${error.name || 'Error'}\` has occurred in the [${NAME}], please look into it @${ADMIN_GITHUB_NAME}.
\`\`\`
${block(error.message || '(no error message)')}
\`\`\`
## More info:
- **Promptbook engine version:** ${PROMPTBOOK_ENGINE_VERSION}
- **Book language version:** ${BOOK_LANGUAGE_VERSION}
- **Time:** ${new Date().toISOString()}
<details>
<summary>Stack trace:</summary>
## Stack trace:
\`\`\`stacktrace
${block(error.stack || '(empty)')}
\`\`\`
</details>
`),
};
const reportUrl = new URL(`https://github.com/webgptorg/promptbook/issues/new`);
reportUrl.searchParams.set('labels', 'bug');
reportUrl.searchParams.set('assignees', ADMIN_GITHUB_NAME);
reportUrl.searchParams.set('title', report.title);
reportUrl.searchParams.set('body', report.body);
return reportUrl;
}
/**
* This error type indicates that the error should not happen and its last check before crashing with some other error
*
* @public exported from `@promptbook/core`
*/
class UnexpectedError extends Error {
constructor(message) {
super(spaceTrim$1((block) => `
${block(message)}
Note: This error should not happen.
It's probbably a bug in the pipeline collection
Please report issue:
${block(getErrorReportUrl(new Error(message)).href)}
Or contact us on ${ADMIN_EMAIL}
`));
this.name = 'UnexpectedError';
Object.setPrototypeOf(this, UnexpectedError.prototype);
}
}
/**
* This error type indicates that somewhere in the code non-Error object was thrown and it was wrapped into the `WrappedError`
*
* @public exported from `@promptbook/core`
*/
class WrappedError extends Error {
constructor(whatWasThrown) {
const tag = `[๐คฎ]`;
console.error(tag, whatWasThrown);
super(spaceTrim$1(`
Non-Error object was thrown
Note: Look for ${tag} in the console for more details
Please report issue on ${ADMIN_EMAIL}
`));
this.name = 'WrappedError';
Object.setPrototypeOf(this, WrappedError.prototype);
}
}
/**
* Helper used in catch blocks to assert that the error is an instance of `Error`
*
* @param whatWasThrown Any object that was thrown
* @returns Nothing if the error is an instance of `Error`
* @throws `WrappedError` or `UnexpectedError` if the error is not standard
*
* @private within the repository
*/
function assertsError(whatWasThrown) {
// Case 1: Handle error which was rethrown as `WrappedError`
if (whatWasThrown instanceof WrappedError) {
const wrappedError = whatWasThrown;
throw wrappedError;
}
// Case 2: Handle unexpected errors
if (whatWasThrown instanceof UnexpectedError) {
const unexpectedError = whatWasThrown;
throw unexpectedError;
}
// Case 3: Handle standard errors - keep them up to consumer
if (whatWasThrown instanceof Error) {
return;
}
// Case 4: Handle non-standard errors - wrap them into `WrappedError` and throw
throw new WrappedError(whatWasThrown);
}
/**
* The built-in `fetch' function with a lightweight error handling wrapper as default fetch function used in Promptbook scrapers
*
* @public exported from `@promptbook/core`
*/
const promptbookFetch = async (urlOrRequest, init) => {
try {
return await fetch(urlOrRequest, init);
}
catch (error) {
assertsError(error);
let url;
if (typeof urlOrRequest === 'string') {
url = urlOrRequest;
}
else if (urlOrRequest instanceof Request) {
url = urlOrRequest.url;
}
throw new PromptbookFetchError(spaceTrim((block) => `
Can not fetch "${url}"
Fetch error:
${block(error.message)}
`));
}
};
/**
* TODO: [๐ง ] Maybe rename because it is not used only for scrapers but also in `$getCompiledBook`
*/
/**
* AuthenticationError is thrown from login function which is dependency of remote server
*
* @public exported from `@promptbook/core`
*/
class AuthenticationError extends Error {
constructor(message) {
super(message);
this.name = 'AuthenticationError';
Object.setPrototypeOf(this, AuthenticationError.prototype);
}
}
/**
* Generates random token
*
* Note: This function is cryptographically secure (it uses crypto.randomBytes internally)
*
* @private internal helper function
* @returns secure random token
*/
function $randomToken(randomness) {
return randomBytes(randomness).toString('hex');
}
/**
* TODO: Maybe use nanoid instead https://github.com/ai/nanoid
*/
/**
* This error indicates errors during the execution of the pipeline
*
* @public exported from `@promptbook/core`
*/
class PipelineExecutionError extends Error {
constructor(message) {
// Added id parameter
super(message);
this.name = 'PipelineExecutionError';
// TODO: [๐] DRY - Maybe $randomId
this.id = `error-${$randomToken(8 /* <- TODO: To global config + Use Base58 to avoid simmilar char conflicts */)}`;
Object.setPrototypeOf(this, PipelineExecutionError.prototype);
}
}
/**
* TODO: [๐ง ][๐] Add id to all errors
*/
/**
* This error indicates problems parsing the format value
*
* For example, when the format value is not a valid JSON or CSV
* This is not thrown directly but in extended classes
*
* @public exported from `@promptbook/core`
*/
class AbstractFormatError extends Error {
// Note: To allow instanceof do not put here error `name`
// public readonly name = 'AbstractFormatError';
constructor(message) {
super(message);
Object.setPrototypeOf(this, AbstractFormatError.prototype);
}
}
/**
* This error indicates problem with parsing of CSV
*
* @public exported from `@promptbook/core`
*/
class CsvFormatError extends AbstractFormatError {
constructor(message) {
super(message);
this.name = 'CsvFormatError';
Object.setPrototypeOf(this, CsvFormatError.prototype);
}
}
/**
* This error indicates that the pipeline collection cannot be propperly loaded
*
* @public exported from `@promptbook/core`
*/
class CollectionError extends Error {
constructor(message) {
super(message);
this.name = 'CollectionError';
Object.setPrototypeOf(this, CollectionError.prototype);
}
}
/**
* This error type indicates that you try to use a feature that is not available in the current environment
*
* @public exported from `@promptbook/core`
*/
class EnvironmentMismatchError extends Error {
constructor(message) {
super(message);
this.name = 'EnvironmentMismatchError';
Object.setPrototypeOf(this, EnvironmentMismatchError.prototype);
}
}
/**
* This error occurs when some expectation is not met in the execution of the pipeline
*
* @public exported from `@promptbook/core`
* Note: Do not throw this error, its reserved for `checkExpectations` and `createPipelineExecutor` and public ONLY to be serializable through remote server
* Note: Always thrown in `checkExpectations` and catched in `createPipelineExecutor` and rethrown as `PipelineExecutionError`
* Note: This is a kindof subtype of PipelineExecutionError
*/
class ExpectError extends Error {
constructor(message) {
super(message);
this.name = 'ExpectError';
Object.setPrototypeOf(this, ExpectError.prototype);
}
}
/**
* This error indicates that the promptbook can not retrieve knowledge from external sources
*
* @public exported from `@promptbook/core`
*/
class KnowledgeScrapeError extends Error {
constructor(message) {
super(message);
this.name = 'KnowledgeScrapeError';
Object.setPrototypeOf(this, KnowledgeScrapeError.prototype);
}
}
/**
* This error type indicates that some limit was reached
*
* @public exported from `@promptbook/core`
*/
class LimitReachedError extends Error {
constructor(message) {
super(message);
this.name = 'LimitReachedError';
Object.setPrototypeOf(this, LimitReachedError.prototype);
}
}
/**
* This error type indicates that some tools are missing for pipeline execution or preparation
*
* @public exported from `@promptbook/core`
*/
class MissingToolsError extends Error {
constructor(message) {
super(spaceTrim$1((block) => `
${block(message)}
Note: You have probbably forgot to provide some tools for pipeline execution or preparation
`));
this.name = 'MissingToolsError';
Object.setPrototypeOf(this, MissingToolsError.prototype);
}
}
/**
* This error indicates that promptbook not found in the collection
*
* @public exported from `@promptbook/core`
*/
class NotFoundError extends Error {
constructor(message) {
super(message);
this.name = 'NotFoundError';
Object.setPrototypeOf(this, NotFoundError.prototype);
}
}
/**
* This error type indicates that some part of the code is not implemented yet
*
* @public exported from `@promptbook/core`
*/
class NotYetImplementedError extends Error {
constructor(message) {
super(spaceTrim$1((block) => `
${block(message)}
Note: This feature is not implemented yet but it will be soon.
If you want speed up the implementation or just read more, look here:
https://github.com/webgptorg/promptbook
Or contact us on pavol@ptbk.io
`));
this.name = 'NotYetImplementedError';
Object.setPrototypeOf(this, NotYetImplementedError.prototype);
}
}
/**
* This error indicates that the promptbook in a markdown format cannot be parsed into a valid promptbook object
*
* @public exported from `@promptbook/core`
*/
class ParseError extends Error {
constructor(message) {
super(message);
this.name = 'ParseError';
Object.setPrototypeOf(this, ParseError.prototype);
}
}
/**
* TODO: Maybe split `ParseError` and `ApplyError`
*/
/**
* This error indicates that the promptbook object has valid syntax (=can be parsed) but contains logical errors (like circular dependencies)
*
* @public exported from `@promptbook/core`
*/
class PipelineLogicError extends Error {
constructor(message) {
super(message);
this.name = 'PipelineLogicError';
Object.setPrototypeOf(this, PipelineLogicError.prototype);
}
}
/**
* This error indicates errors in referencing promptbooks between each other
*
* @public exported from `@promptbook/core`
*/
class PipelineUrlError extends Error {
constructor(message) {
super(message);
this.name = 'PipelineUrlError';
Object.setPrototypeOf(this, PipelineUrlError.prototype);
}
}
/**
* Index of all custom errors
*
* @public exported from `@promptbook/core`
*/
const PROMPTBOOK_ERRORS = {
AbstractFormatError,
CsvFormatError,
CollectionError,
EnvironmentMismatchError,
ExpectError,
KnowledgeScrapeError,
LimitReachedError,
MissingToolsError,
NotFoundError,
NotYetImplementedError,
ParseError,
PipelineExecutionError,
PipelineLogicError,
PipelineUrlError,
AuthenticationError,
PromptbookFetchError,
UnexpectedError,
WrappedError,
// TODO: [๐ช]> VersionMismatchError,
};
/**
* Index of all javascript errors
*
* @private for internal usage
*/
const COMMON_JAVASCRIPT_ERRORS = {
Error,
EvalError,
RangeError,
ReferenceError,
SyntaxError,
TypeError,
URIError,
AggregateError,
/*
Note: Not widely supported
> InternalError,
> ModuleError,
> HeapError,
> WebAssemblyCompileError,
> WebAssemblyRuntimeError,
*/
};
/**
* Index of all errors
*
* @private for internal usage
*/
const ALL_ERRORS = {
...PROMPTBOOK_ERRORS,
...COMMON_JAVASCRIPT_ERRORS,
};
/**
* Note: [๐] Ignore a discrepancy between file name and entity name
*/
/**
* Serializes an error into a [๐] JSON-serializable object
*
* @public exported from `@promptbook/utils`
*/
function serializeError(error) {
const { name, message, stack } = error;
const { id } = error;
if (!Object.keys(ALL_ERRORS).includes(name)) {
console.error(spaceTrim((block) => `
Cannot serialize error with name "${name}"
Authors of Promptbook probably forgot to add this error into the list of errors:
https://github.com/webgptorg/promptbook/blob/main/src/errors/0-index.ts
${block(stack || message)}
`));
}
return {
name: name,
message,
stack,
id, // Include id in the serialized object
};
}
/**
* Detects if the code is running in a Node.js environment
*
* Note: `$` is used to indicate that this function is not a pure function - it looks at the global object to determine the environment
*
* @public exported from `@promptbook/utils`
*/
const $isRunningInNode = new Function(`
try {
return this === global;
} catch (e) {
return false;
}
`);
/**
* TODO: [๐บ]
*/
/**
* Normalize options for `execCommand` and `execCommands`
*
* Note: `$` is used to indicate that this function behaves differently according to `process.platform`
*
* @private internal utility of `execCommand` and `execCommands`
*/
function $execCommandNormalizeOptions(options) {
var _a, _b, _c, _d;
let command;
let cwd;
let crashOnError;
let args = [];
let timeout;
let isVerbose;
if (typeof options === 'string') {
// TODO: [1] DRY default values
command = options;
cwd = process.cwd();
crashOnError = true;
timeout = Infinity; // <- TODO: [โณ]
isVerbose = DEFAULT_IS_VERBOSE;
}
else {
/*
TODO:
if ((options as any).commands !== undefined) {
commands = (options as any).commands;
} else {
commands = [(options as any).command];
}
*/
// TODO: [1] DRY default values
command = options.command;
cwd = (_a = options.cwd) !== null && _a !== void 0 ? _a : process.cwd();
crashOnError = (_b = options.crashOnError) !== null && _b !== void 0 ? _b : true;
timeout = (_c = options.timeout) !== null && _c !== void 0 ? _c : Infinity;
isVerbose = (_d = options.isVerbose) !== null && _d !== void 0 ? _d : DEFAULT_IS_VERBOSE;
}
// TODO: /(-[a-zA-Z0-9-]+\s+[^\s]*)|[^\s]*/g
const _ = Array.from(command.matchAll(/(".*")|([^\s]*)/g))
.map(([match]) => match)
.filter((arg) => arg !== '');
if (_.length > 1) {
[command, ...args] = _;
}
if (options.args) {
args = [...args, ...options.args];
}
let humanReadableCommand = !['npx', 'npm'].includes(command) ? command : args[0];
if (['ts-node'].includes(humanReadableCommand)) {
humanReadableCommand += ` ${args[1]}`;
}
if (/^win/.test(process.platform) && ['npm', 'npx'].includes(command)) {
command = `${command}.cmd`;
}
return { command, humanReadableCommand, args, cwd, crashOnError, timeout, isVerbose };
}
// TODO: This should show type error> execCommandNormalizeOptions({ command: '', commands: [''] });
/**
* Run one command in a shell
*
*
* Note: There are 2 similar functions in the codebase:
* - `$execCommand` which runs a single command
* - `$execCommands` which runs multiple commands
* Note: `$` is used to indicate that this function is not a pure function - it runs a command in a shell
*
* @public exported from `@promptbook/node`
*/
function $execCommand(options) {
if (!$isRunningInNode()) {
throw new EnvironmentMismatchError('Function `$execCommand` can run only in Node environment.js');
}
return new Promise((resolve, reject) => {
// eslint-disable-next-line prefer-const
const { command, humanReadableCommand, args, cwd, crashOnError, timeout, isVerbose = DEFAULT_IS_VERBOSE, } = $execCommandNormalizeOptions(options);
if (timeout !== Infinity) {
// TODO: In waitasecond forTime(Infinity) should be equivalent to forEver()
forTime(timeout).then(() => {
if (crashOnError) {
reject(new Error(`Command "${humanReadableCommand}" exceeded time limit of ${timeout}ms`));
}
else {
console.warn(`Command "${humanReadableCommand}" exceeded time limit of ${timeout}ms but continues running`);
// <- TODO: [๐ฎ] Some standard way how to transform errors into warnings and how to handle non-critical fails during the tasks
resolve('Command exceeded time limit');
}
});
}
if (isVerbose) {
console.info(colors.yellow(cwd) + ' ' + colors.green(command) + ' ' + colors.blue(args.join(' ')));
}
try {
const commandProcess = spawn(command, args, { cwd, shell: true });
if (isVerbose) {
commandProcess.on('message', (message) => {
console.info({ message });
});
}
const output = [];
commandProcess.stdout.on('data', (stdout) => {
output.push(stdout.toString());
if (isVerbose) {
console.info(stdout.toString());
}
});
commandProcess.stderr.on('data', (stderr) => {
output.push(stderr.toString());
if (isVerbose && stderr.toString().trim()) {
console.warn(stderr.toString());
// <- TODO: [๐ฎ] Some standard way how to transform errors into warnings and how to handle non-critical fails during the tasks
}
});
const finishWithCode = (code) => {
if (code !== 0) {
if (crashOnError) {
reject(new Error(output.join('\n').trim() ||
`Command "${humanReadableCommand}" exited with code ${code}`));
}
else {
if (isVerbose) {
console.warn(`Command "${humanReadableCommand}" exited with code ${code}`);
// <- TODO: [๐ฎ] Some standard way how to transform errors into warnings and how to handle non-critical fails during the tasks
}
resolve(spaceTrim$1(output.join('\n')));
}
}
else {
resolve(spaceTrim$1(output.join('\n')));
}
};
commandProcess.on('close', finishWithCode);
commandProcess.on('exit', finishWithCode);
commandProcess.on('disconnect', () => {
// Note: Unexpected disconnection should always result in rejection
reject(new Error(`Command "${humanReadableCommand}" disconnected`));
});
commandProcess.on('error', (error) => {
if (crashOnError) {
reject(new Error(`Command "${humanReadableCommand}" failed: \n${error.message}`));
}
else {
if (isVerbose) {
console.warn(error);
// <- TODO: [๐ฎ] Some standard way how to transform errors into warnings and how to handle non-critical fails during the tasks
}
resolve(spaceTrim$1(output.join('\n')));
}
});
}
catch (error) {
// Note: Unexpected error in sync code should always result in rejection
reject(error);
}
});
}
/**
* Note: [๐ข] Code in this file should never be never released in packages that could be imported into browser environment
*/
/**
* @@@
*
* @private within the repository
*/
async function locateAppOnLinux({ linuxWhich, }) {
try {
const result = await $execCommand({ crashOnError: true, command: `which ${linuxWhich}` });
return result.trim();
}
catch (error) {
assertsError(error);
return null;
}
}
/**
* TODO: [๐ง ][โฟ] Maybe export through `@promptbook/node`
* Note: [๐ข] Code in this file should never be never released in packages that could be imported into browser environment
*/
/**
* @@@
*
* @public exported from `@promptbook/node`
*/
function $provideFilesystemForNode(options) {
if (!$isRunningInNode()) {
throw new EnvironmentMismatchError('Function `$provideFilesystemForNode` works only in Node.js environment');
}
return {
stat,
access,
constants,
readFile,
writeFile,
readdir,
mkdir,
};
}
/**
* Note: [๐ข] Code in this file should never be never released in packages that could be imported into browser environment
*/
/**
* Checks if the file is executable
*
* @private within the repository
*/
async function isExecutable(path, fs) {
try {
await fs.access(path, fs.constants.X_OK);
return true;
}
catch (error) {
return false;
}
}
/**
* Note: Not [~๐ข~] because it is not directly dependent on `fs
* TODO: [๐] What about symlinks?
*/
// Note: Module `userhome` has no types available, so it is imported using `require`
// @see https://stackoverflow.com/questions/37000981/how-to-import-node-module-in-typescript-without-type-definitions
// eslint-disable-next-line @typescript-eslint/no-var-requires
const userhome = require('userhome');
/**
* @@@
*
* @private within the repository
*/
async function locateAppOnMacOs({ macOsName, }) {
try {
const toExec = `/Contents/MacOS/${macOsName}`;
const regPath = `/Applications/${macOsName}.app` + toExec;
const altPath = userhome(regPath.slice(1));
if (await isExecutable(regPath, $provideFilesystemForNode())) {
return regPath;
}
else if (await isExecutable(altPath, $provideFilesystemForNode())) {
return altPath;
}
const result = await $execCommand({
crashOnError: true,
command: `mdfind 'kMDItemDisplayName == "${macOsName}" && kMDItemKind == Application'`,
});
return result.trim() + toExec;
}
catch (error) {
assertsError(error);
return null;
}
}
/**
* TODO: [๐ง ][โฟ] Maybe export through `@promptbook/node`
* Note: [๐ข] Code in this file should never be never released in packages that could be imported into browser environment
*/
/**
* @@@
*
* @private within the repository
*/
async function locateAppOnWindows({ appName, windowsSuffix, }) {
try {
const prefixes = [
process.env.LOCALAPPDATA,
join(process.env.LOCALAPPDATA || '', 'Programs'),
process.env.PROGRAMFILES,
process.env['PROGRAMFILES(X86)'],
];
for (const prefix of prefixes) {
const path = prefix + windowsSuffix;
if (await isExecutable(path, $provideFilesystemForNode())) {
return path;
}
}
throw new Error(`Can not locate app ${appName} on Windows.`);
}
catch (error) {
assertsError(error);
return null;
}
}
/**
* TODO: [๐ง ][โฟ] Maybe export through `@promptbook/node`
* Note: [๐ข] Code in this file should never be never released in packages that could be imported into browser environment
*/
/**
* Locates an application on the system
*
* @private within the repository
*/
function locateApp(options) {
if (!$isRunningInNode()) {
throw new EnvironmentMismatchError('Locating apps works only in Node.js environment');
}
const { appName, linuxWhich, windowsSuffix, macOsName } = options;
if (process.platform === 'win32') {
if (windowsSuffix) {
return locateAppOnWindows({ appName, windowsSuffix });
}
else {
throw new Error(`${appName} is not available on Windows.`);
}
}
else if (process.platform === 'darwin') {
if (macOsName) {
return locateAppOnMacOs({ macOsName });
}
else {
throw new Error(`${appName} is not available on macOS.`);
}
}
else {
if (linuxWhich) {
return locateAppOnLinux({ linuxWhich });
}
else {
throw new Error(`${appName} is not available on Linux.`);
}
}
}
/**
* TODO: [๐ง ][โฟ] Maybe export through `@promptbook/node`
* Note: [๐ข] Code in this file should never be never released in packages that could be imported into browser environment
*/
/**
* @@@
*
* @private within the repository
*/
function locateLibreoffice() {
return locateApp({
appName: 'Libreoffice',
linuxWhich: 'libreoffice',
windowsSuffix: '\\LibreOffice\\program\\soffice.exe',
macOsName: 'LibreOffice',
});
}
/**
* TODO: [๐ง ][โฟ] Maybe export through `@promptbook/node` OR `@promptbook/legacy-documents`
* Note: [๐ข] Code in this file should never be never released in packages that could be imported into browser environment
*/
/**
* @@@
*
* @private within the repository
*/
function locatePandoc() {
return locateApp({
appName: 'Pandoc',
linuxWhich: 'pandoc',
windowsSuffix: '\\Pandoc\\pandoc.exe',
macOsName: 'Pandoc',
});
}
/**
* TODO: [๐ง ][โฟ] Maybe export through `@promptbook/node` OR `@promptbook/documents`
* Note: [๐ข] Code in this file should never be never released in packages that could be imported into browser environment
*/
/**
* @@@
*
* @public exported from `@promptbook/node`
*/
async function $provideExecutablesForNode(options) {
if (!$isRunningInNode()) {
throw new EnvironmentMismatchError('Function `$getScrapersForNode` works only in Node.js environment');
}
return {
pandocPath: (await locatePandoc()) || undefined,
libreOfficePath: (await locateLibreoffice()) || undefined,
// <- TODO: [๐ง ] `null` vs `undefined`
};
}
/**
* TODO: [๐ง ] Allow to override the executables without need to call `locatePandoc` / `locateLibreoffice` in case of provided
* Note: [๐ข] Code in this file should never be never released in packages that could be imported into browser environment
*/
/**
* Orders JSON object by keys
*
* @returns The same type of object as the input re-ordered
* @public exported from `@promptbook/utils`
*/
function orderJson(options) {
const { value, order } = options;
const orderedValue = {
...(order === undefined ? {} : Object.fromEntries(order.map((key) => [key, undefined]))),
...value,
};
return orderedValue;
}
/**
* Freezes the given object and all its nested objects recursively
*
* Note: `$` is used to indicate that this function is not a pure function - it mutates given object
* Note: This function mutates the object and returns the original (but mutated-deep-freezed) object
*
* @returns The same object as the input, but deeply frozen
* @public exported from `@promptbook/utils`
*/
function $deepFreeze(objectValue) {
if (Array.isArray(objectValue)) {
return Object.freeze(objectValue.map((item) => $deepFreeze(item)));
}
const propertyNames = Object.getOwnPropertyNames(objectValue);
for (const propertyName of propertyNames) {
const value = objectValue[propertyName];
if (value && typeof value === 'object') {
$deepFreeze(value);
}
}
Object.freeze(objectValue);
return objectValue;
}
/**
* TODO: [๐ง ] Is there a way how to meaningfully test this utility
*/
/**
* Checks if the value is [๐] serializable as JSON
* If not, throws an UnexpectedError with a rich error message and tracking
*
* - Almost all primitives are serializable BUT:
* - `undefined` is not serializable
* - `NaN` is not serializable
* - Objects and arrays are serializable if all their properties are serializable
* - Functions are not serializable
* - Circular references are not serializable
* - `Date` objects are not serializable
* - `Map` and `Set` objects are not serializable
* - `RegExp` objects are not serializable
* - `Error` objects are not serializable
* - `Symbol` objects are not serializable
* - And much more...
*
* @throws UnexpectedError if the value is not serializable as JSON
* @public exported from `@promptbook/utils`
*/
function checkSerializableAsJson(options) {
const { value, name, message } = options;
if (value === undefined) {
throw new UnexpectedError(`${name} is undefined`);
}
else if (value === null) {
return;
}
else if (typeof value === 'boolean') {
return;
}
else if (typeof value === 'number' && !isNaN(value)) {
return;
}
else if (typeof value === 'string') {
return;
}
else if (typeof value === 'symbol') {
throw new UnexpectedError(`${name} is symbol`);
}
else if (typeof value === 'function') {
throw new UnexpectedError(`${name} is function`);
}
else if (typeof value === 'object' && Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
checkSerializableAsJson({ name: `${name}[${i}]`, value: value[i], message });
}
}
else if (typeof value === 'object') {
if (value instanceof Date) {
throw new UnexpectedError(spaceTrim((block) => `
\`${name}\` is Date
Use \`string_date_iso8601\` instead
Additional message for \`${name}\`:
${block(message || '(nothing)')}
`));
}
else if (value instanceof Map) {
throw new UnexpectedError(`${name} is Map`);
}
else if (value instanceof Set) {
throw new UnexpectedError(`${name} is Set`);
}
else if (value instanceof RegExp) {
throw new UnexpectedError(`${name} is RegExp`);
}
else if (value instanceof Error) {
throw new UnexpectedError(spaceTrim((block) => `
\`${name}\` is unserialized Error
Use function \`serializeError\`
Additional message for \`${name}\`:
${block(message || '(nothing)')}
`));
}
else {
for (const [subName, subValue] of Object.entries(value)) {
if (subValue === undefined) {
// Note: undefined in object is serializable - it is just omited
continue;
}
checkSerializableAsJson({ name: `${name}.${subName}`, value: subValue, message });
}
try {
JSON.stringify(value); // <- TODO: [0]
}
catch (error) {
assertsError(error);
throw new UnexpectedError(spaceTrim((block) => `
\`${name}\` is not serializable
${block(error.stack || error.message)}
Additional message for \`${name}\`:
${block(message || '(nothing)')}
`));
}
/*
TODO: [0] Is there some more elegant way to check circular references?
const seen = new Set();
const stack = [{ value }];
while (stack.length > 0) {
const { value } = stack.pop()!;
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
throw new UnexpectedError(`${name} has circular reference`);
}
seen.add(value);
if (Array.isArray(value)) {
stack.push(...value.map((value) => ({ value })));
} else {
stack.push(...Object.values(value).map((value) => ({ value })));
}
}
}
*/
return;
}
}
else {
throw new UnexpectedError(spaceTrim((block) => `
\`${name}\` is unknown type
Additional message for \`${name}\`:
${block(message || '(nothing)')}
`));
}
}
/**
* TODO: Can be return type more type-safe? like `asserts options.value is JsonValue`
* TODO: [๐ง ][main] !!3 In-memory cache of same values to prevent multiple checks
* Note: [๐ ] This is how `checkSerializableAsJson` + `isSerializableAsJson` together can just retun true/false or rich error message
*/
/**
* @@@
*
* @public exported from `@promptbook/utils`
*/
function deepClone(objectValue) {
return JSON.parse(JSON.stringify(objectValue));
/*
TODO: [๐ง ] Is there a better implementation?
> const propertyNames = Object.getOwnPropertyNames(objectValue);
> for (const propertyName of propertyNames) {
> const value = (objectValue as really_any)[propertyName];
> if (value && typeof value === 'object') {
> deepClone(value);
> }
> }
> return Object.assign({}, objectValue);
*/
}
/**
* TODO: [๐ง ] Is there a way how to meaningfully test this utility
*/
/**
* Utility to export a JSON object from a function
*
* 1) Checks if the value is serializable as JSON
* 2) Makes a deep clone of the object
* 2) Orders the object properties
* 2) Deeply freezes the cloned object
*
* Note: This function does not mutates the given object
*
* @returns The same type of object as the input but read-only and re-ordered
* @public exported from `@promptbook/utils`
*/
function exportJson(options) {
const { name, value, order, message } = options;
checkSerializableAsJson({ name, value, message });
const orderedValue =
// TODO: Fix error "Type instantiation is excessively deep and possibly infinite."
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
order === undefined
? deepClone(value)
: orderJson({
value: value,
// <- Note: checkSerializableAsJson asserts that the value is serializable as JSON
order: order,
});
$deepFreeze(orderedValue);
return orderedValue;
}
/**
* TODO: [๐ง ] Is there a way how to meaningfully test this utility
*/
/**
* Order of keys in the pipeline JSON
*
* @public exported from `@promptbook/core`
*/
const ORDER_OF_PIPELINE_JSON = [
// Note: [๐] In this order will be pipeline serialized
'title',
'pipelineUrl',
'bookVersion',
'description',
'formfactorName',
'parameters',
'tasks',
'personas',
'preparations',
'knowledgeSources',
'knowledgePieces',
'sources', // <- TODO: [๐ง ] Where should the `sources` be
];
/**
* Nonce which is used for replacing things in strings
*
* @private within the repository
*/
const REPLACING_NONCE = 'ptbkauk42kV2dzao34faw7FudQUHYPtW';
/**
* @@@
*
* @private within the repository
*/
const RESERVED_PARAMETER_MISSING_VALUE = 'MISSING-' + REPLACING_NONCE;
/**
* @@@
*
* @private within the repository
*/
const RESERVED_PARAMETER_RESTRICTED = 'RESTRICTED-' + REPLACING_NONCE;
/**
* The names of the parameters that are reserved for special purposes
*
* @public exported from `@promptbook/core`
*/
const RESERVED_PARAMETER_NAMES = exportJson({
name: 'RESERVED_PARAMETER_NAMES',
message: `The names of the parameters that are reserved for special purposes`,
value: [
'content',
'context',
'knowledge',
'examples',
'modelName',
'currentDate',
// <- TODO: list here all command names
// <- TODO: Add more like 'date', 'modelName',...
// <- TODO: Add [emoji] + instructions ACRY when adding new reserved parameter
],
});
/**
* Note: [๐] Ignore a discrepancy between file name and entity name
*/
/**
* Tests if given string is valid semantic version
*
* Note: There are two simmilar functions:
* - `isValidSemanticVersion` which tests any semantic version
* - `isValidPromptbookVersion` *(this one)* which tests just Promptbook versions
*
* @public exported from `@promptbook/utils`
*/
function isValidSemanticVersion(version) {
if (typeof version !== 'string') {
return false;
}
if (version.startsWith('0.0.0')) {
return false;
}
return /^\d+\.\d+\.\d+(-\d+)?$/i.test(version);
}
/**
* Tests if given string is valid promptbook version
* It looks into list of known promptbook versions.
*
* @see https://www.npmjs.com/package/promptbook?activeTab=versions
* Note: When you are using for example promptbook 2.0.0 and there already is promptbook 3.0.0 it don`t know about it.
* Note: There are two simmilar functions:
* - `isValidSemanticVersion` which tests any semantic version
* - `isValidPromptbookVersion` *(this one)* which tests just Promptbook versions
*
* @public exported from `@promptbook/utils`
*/
function isValidPromptbookVersion(version) {
if (!isValidSemanticVersion(version)) {
return false;
}
if ( /* version === '1.0.0' || */version === '2.0.0' || version === '3.0.0') {
return false;
}
// <- TODO: [main] !!3 Check isValidPromptbookVersion against PROMPTBOOK_ENGINE_VERSIONS
return true;
}
/**
* Tests if given string is valid URL.
*
* Note: Dataurl are considered perfectly valid.
* Note: There are two simmilar functions:
* - `isValidUrl` which tests any URL
* - `isValidPipelineUrl` *(this one)* which tests just promptbook URL
*
* @public exported from `@promptbook/utils`
*/
function isValidUrl(url) {
if (typeof url !== 'string') {
return false;
}
try {
if (url.startsWith('blob:')) {
url = url.replace(/^blob:/, '');
}
const urlObject = new URL(url /* because fail is handled */);
if (!['http:', 'https:', 'data:'].includes(urlObject.protocol)) {
return false;
}
return true;
}
catch (error) {
return false;
}
}
/**
* Tests if given string is valid pipeline URL URL.
*
* Note: There are two simmilar functions:
* - `isValidUrl` which tests any URL
* - `isValidPipelineUrl` *(this one)* which tests just pipeline URL
*
* @public exported from `@promptbook/utils`
*/
function isValidPipelineUrl(url) {
if (!isValidUrl(url)) {
return false;
}
if (!url.startsWith('https://') && !url.startsWith('http://') /* <- Note: [๐ฃ] */) {
return false;
}
if (url.includes('#')) {
// TODO: [๐ ]
return false;
}
/*
Note: [๐ฃ][๐ง ] Is it secure to allow pipeline URLs on private and unsecured networks?
if (isUrlOnPrivateNetwork(url)) {
return false;
}
*/
return true;
}
/**
* TODO: [๐ ] Maybe more info why the URL is invalid
*/
/**
* Validates PipelineJson if it is logically valid
*
* It checks:
* - if it has correct parameters dependency
*
* It does NOT check:
* - if it is valid json
* - if it is meaningful
*
* @param pipeline valid or invalid PipelineJson
* @returns the same pipeline if it is logically valid
* @throws {PipelineLogicError} on logical error in the pipeline
* @public exported from `@promptbook/core`
*/
function validatePipeline(pipeline) {
if (IS_PIPELINE_LOGIC_VALIDATED) {
validatePipeline_InnerFunction(pipeline);
}
else {
try {
validatePipeline_InnerFunction(pipeline);
}
catch (error) {
if (!(error instanceof PipelineLogicError)) {
throw error;
}
console.error(spaceTrim$1((block) => `
Pipeline is not valid but logic errors are temporarily disabled via \`IS_PIPELINE_LOGIC_VALIDATED\`
${block(error.message)}
`));
}
}
return pipeline;
}
/**
* @private internal function for `validatePipeline`
*/
function validatePipeline_InnerFunction(pipeline) {
// TODO: [๐ง ] Maybe test if promptbook is a promise and make specific error case for that
const pipelineIdentification = (() => {
// Note: This is a ๐ implementation of [๐]
const _ = [];
if (pipeline.sourceFile !== undefined) {
_.push(`File: ${pipeline.sourceFile}`);
}
if (pipeline.pipelineUrl !== undefined) {
_.push(`Url: ${pipeline.pipelineUrl}`);
}
return _.join('\n');
})();
if (pipeline.pipelineUrl !== undefined && !isValidPipelineUrl(pipeline.pipelineUrl)) {
// <- Note: [๐ฒ]
throw new PipelineLogicError(spaceTrim$1((block) => `
Invalid promptbook URL "${pipeline.pipelineUrl}"
${block(pipelineIdentification)}
`));
}
if (pipeline.bookVersion !== undefined && !isValidPromptbookVersion(pipeline.bookVersion)) {
// <- Note: [๐ฒ]
throw new PipelineLogicError(spaceTrim$1((block) => `
Invalid Promptbook Version "${pipeline.bookVersion}"
${block(pipelineIdentification)}
`));
}
// TODO: [๐ง ] Maybe do here some propper JSON-schema / ZOD checking
if (!Array.isArray(pipeline.parameters)) {
// TODO: [๐ง ] what is the correct error tp throw - maybe PromptbookSchemaError
throw new ParseError(spaceTrim$1((block) => `
Pipeline is valid JSON but with wrong structure
\`PipelineJson.parameters\` expected to be an array, but got ${typeof pipeline.parameters}
${block(pipelineIdentification)}
`));
}
// TODO: [๐ง ] Maybe do here some propper JSON-schema / ZOD checking
if (!Array.isArray(pipeline.tasks)) {
// TODO: [๐ง ] what is the correct error tp throw - maybe PromptbookSchemaError
throw new ParseError(spaceTrim$1((block) => `
Pipeline is valid JSON but with wrong structure
\`PipelineJson.tasks\` expected to be an array, but got ${typeof pipeline.tasks}
${block(pipelineIdentification)}
`));
}
/*
TODO: [๐ง ][๐
พ] Should be empty pipeline valid or not
// Note: Check that pipeline has some tasks
if (pipeline.tasks.length === 0) {
throw new PipelineLogicError(
spaceTrim(
(block) => `
Pipeline must have at least one task
${block(pipelineIdentification)}
`,