diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d2804d4ffa..3d13a7fb43 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: compile tests run: pnpm run test:compile - continue-on-error: true + - name: runtime tests + run: pnpm run test:runtime - name: unit tests run: pnpm run test:samples env: diff --git a/.gitignore b/.gitignore index b004ca295f..238c624e92 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ genaiscript*.tgz # START Ruler Generated Files .github/copilot-instructions.md # END Ruler Generated Files + +packages/*/src/package.json diff --git a/.vscode/settings.json b/.vscode/settings.json index ec22a84223..4b0e3299e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -40,6 +40,7 @@ "chattypes", "Chunker", "chunkers", + "clihelp", "cmds", "cmsg", "codelion", @@ -95,6 +96,7 @@ "fallbacktools", "fetchtext", "ffprobe", + "filetree", "firstsecond", "Fmepg", "frontmatter", @@ -160,6 +162,7 @@ "makeitbetter", "managedidentity", "markdownify", + "markdownifypdf", "markitdown", "mcpclient", "mcpresource", diff --git a/docs/src/content/docs/reference/runtime.mdx b/docs/src/content/docs/reference/runtime.mdx new file mode 100644 index 0000000000..1ba8e3b951 --- /dev/null +++ b/docs/src/content/docs/reference/runtime.mdx @@ -0,0 +1,23 @@ +--- +title: Runtime +sidebar: + order: 1.2 +description: Learn how to run GenAIScript scripts in a Node.JS environment, including configuration +--- + +Scripts using the `.genai.mts` (or any `.genai.*` extension) are meant to be run through the [command line interfanece](/genaiscript/reference/cli). + +This page describes how to use the runtime in a Node.JS script without using the CLI. + +## Import and configuration + +```js +import { initialize } from "@genaiscript/runtime"; + +// runs this before using any global ty\pes +await initialize(); +``` + +## globals + +The runtime installs global parsers and inline prompt types. However, the global `$`, `def`, etc... is not available, online inline prompts. diff --git a/package.json b/package.json index ba2bf0b133..623a7588a0 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,8 @@ "test:scripts": "cd packages/sample/ && pnpm test:scripts", "test:scripts:view": "cd packages/sample/ && pnpm test:scripts:view", "test:system": "cd packages/core && node ../cli/dist/src/index.js scripts compile", + "test:runtime": "cd packages/runtime && pnpm run test", + "tsx": "tsx", "typecheck": "echo skipped", "typecheck:web": "pnpm --filter=@genaiscript/web run typecheck", "whisper": "pnpm whisper:stop && pnpm whisper:start", @@ -108,7 +110,7 @@ } }, "devDependencies": { - "@inquirer/prompts": "^7.5.3", + "@inquirer/prompts": "catalog:", "@intellectronica/ruler": "^0.2.3", "@modelcontextprotocol/inspector": "^0.14.3", "npm-check-updates": "^18.0.1", @@ -117,6 +119,7 @@ "prettier": "catalog:", "prettier-plugin-curly": "^0.3.2", "tailwindcss": "^4.1.10", + "tsx": "catalog:", "turbo": "^2.5.4", "zx": "catalog:" } diff --git a/packages/api/src/run.ts b/packages/api/src/run.ts index a0237d0412..5f99d7ae74 100644 --- a/packages/api/src/run.ts +++ b/packages/api/src/run.ts @@ -512,7 +512,7 @@ export async function runScriptInternal( topLogprobs, fenceFormat, runDir, - applyGitIgnore, + applyGitIgnore, cliInfo: options.cli ? { files, diff --git a/packages/cli/package.json b/packages/cli/package.json index 1ecfa8dd33..b30c1830ef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -39,7 +39,7 @@ "@genaiscript/api": "workspace:*", "@genaiscript/core": "workspace:*", "@genaiscript/runtime": "workspace:*", - "@inquirer/prompts": "^7.5.3", + "@inquirer/prompts": "catalog:", "@modelcontextprotocol/sdk": "^1.13.1", "chokidar": "^4.0.3", "commander": "^12.1.0", diff --git a/packages/cli/src/action.ts b/packages/cli/src/action.ts index 7db906d508..7c2dc7da92 100644 --- a/packages/cli/src/action.ts +++ b/packages/cli/src/action.ts @@ -260,7 +260,12 @@ export async function actionConfigure( ].filter(Boolean); const actionYmlFilename = resolve(out, "action.yml"); - const action = YAMLTryParse(await tryReadText(actionYmlFilename)); + const action = YAMLTryParse(await tryReadText(actionYmlFilename)) as { + description?: string; + inputs?: Record; + outputs?: Record; + branding?: Record; + }; if (action && !force) { logVerbose(`updating action.yml`); action.description = script.description || pkg?.description; diff --git a/packages/core/package.json b/packages/core/package.json index 4d516ab4d9..24046e88d5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -124,7 +124,7 @@ "toml": "^3.0.0", "ts-dedent": "^2.2.0", "tslib": "catalog:", - "tsx": "^4.19.4", + "tsx": "catalog:", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", "undici": "^7.10.0", diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index b26f0372b8..3804a42d20 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -111,7 +111,7 @@ export async function agentAddMemory( }; dbg(`add ${agent}: ${ellipse(query, 80)} -> ${ellipse(text, 128)}`); await cache.set(cacheKey, cachedValue); - trace.detailsFenced( + trace?.detailsFenced( `🧠 agent memory: ${HTMLEscape(query)}`, HTMLEscape(prettifyMarkdown(cachedValue.answer)), "markdown", @@ -139,6 +139,7 @@ export async function traceAgentMemory( options: Pick & Required, ) { const { trace } = options || {}; + if (!trace) return; const cache = agentCreateCache({ userState: options.userState, lookupOnly: true, @@ -146,18 +147,18 @@ export async function traceAgentMemory( const memories = await loadMemories(cache); if (memories?.length) { try { - trace.startDetails("🧠 agent memory"); + trace?.startDetails("🧠 agent memory"); memories .reverse() .forEach(({ agent, query, answer }) => - trace.detailsFenced( + trace?.detailsFenced( `👤 ${agent}: ${HTMLEscape(query)}`, HTMLEscape(prettifyMarkdown(answer)), "markdown", ), ); } finally { - trace.endDetails(); + trace?.endDetails(); } } } diff --git a/packages/core/src/anthropic.ts b/packages/core/src/anthropic.ts index e475053e6a..0a1b995e22 100644 --- a/packages/core/src/anthropic.ts +++ b/packages/core/src/anthropic.ts @@ -160,6 +160,7 @@ const convertAssistantMessage = ( signature: msg.signature, } satisfies Anthropic.ThinkingBlockParam) : undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any ...((convertStandardMessage(msg)?.content || []) as any), ...(msg.tool_calls || []).map( (tool) => @@ -305,7 +306,7 @@ const completerFactory = ( const httpAgent = await resolveHttpProxyAgent(); const messagesApi = await resolver(trace, cfg, httpAgent, fetch); dbg("caching", caching); - trace.itemValue(`caching`, caching); + trace?.itemValue(`caching`, caching); let numTokens = 0; let chatResp = ""; @@ -363,8 +364,8 @@ const completerFactory = ( } dbgMessages(`messages: %O`, messages); - trace.detailsFenced("✉️ body", mreq, "json"); - trace.appendContent("\n"); + trace?.detailsFenced("✉️ body", mreq, "json"); + trace?.appendContent("\n"); try { const stream = messagesApi.stream({ ...mreq, ...headers }); @@ -399,9 +400,9 @@ const completerFactory = ( break; case "thinking_delta": reasoningContent = chunk.delta.thinking; - trace.appendToken(reasoningContent); + trace?.appendToken(reasoningContent); reasoningChatResp += reasoningContent; - trace.appendToken(chunkContent); + trace?.appendToken(chunkContent); break; case "text_delta": if (!chunk.delta.text) dbg(`empty text_delta`, chunk); @@ -409,7 +410,7 @@ const completerFactory = ( chunkContent = chunk.delta.text; numTokens += approximateTokens(chunkContent, { encoder }); chatResp += chunkContent; - trace.appendToken(chunkContent); + trace?.appendToken(chunkContent); } break; @@ -448,13 +449,13 @@ const completerFactory = ( } catch (e) { finishReason = "fail"; logError(e); - trace.error("error while processing event", serializeError(e)); + trace?.error("error while processing event", serializeError(e)); } - trace.appendContent("\n\n"); - trace.itemValue(`🏁 finish reason`, finishReason); + trace?.appendContent("\n\n"); + trace?.itemValue(`🏁 finish reason`, finishReason); if (usage?.total_tokens) { - trace.itemValue( + trace?.itemValue( `🪙 tokens`, `${usage.total_tokens} total, ${usage.prompt_tokens} prompt, ${usage.completion_tokens} completion`, ); @@ -472,7 +473,7 @@ const completerFactory = ( return completion; }; -const listModels: ListModelsFunction = async (cfg, options) => { +const listModels: ListModelsFunction = async (cfg) => { try { const anthropic = new Anthropic({ baseURL: cfg.base, @@ -507,9 +508,10 @@ export const AnthropicModel = Object.freeze({ fetch, fetchOptions: { dispatcher: httpAgent, + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as RequestInit as any, }); - if (anthropic.baseURL) trace.itemValue(`url`, `[${anthropic.baseURL}](${anthropic.baseURL})`); + if (anthropic.baseURL) trace?.itemValue(`url`, `[${anthropic.baseURL}](${anthropic.baseURL})`); const messagesApi = anthropic.beta.messages; return messagesApi; }), @@ -524,9 +526,10 @@ export const AnthropicBedrockModel = Object.freeze({ fetch, fetchOptions: { dispatcher: httpAgent, + // eslint-disable-next-line @typescript-eslint/no-explicit-any } as RequestInit as any, }); - if (anthropic.baseURL) trace.itemValue(`url`, `[${anthropic.baseURL}](${anthropic.baseURL})`); + if (anthropic.baseURL) trace?.itemValue(`url`, `[${anthropic.baseURL}](${anthropic.baseURL})`); return anthropic.beta.messages; }), id: MODEL_PROVIDER_ANTHROPIC_BEDROCK, diff --git a/packages/core/src/chat.ts b/packages/core/src/chat.ts index 68d45c2699..9797d04f31 100644 --- a/packages/core/src/chat.ts +++ b/packages/core/src/chat.ts @@ -253,7 +253,7 @@ async function runToolCalls( for (const call of resp.toolCalls) { checkCancelled(cancellationToken); dbgt(`running tool call %s`, call.name); - const toolTrace = trace.startTraceDetails(`📠 tool call ${call.name}`); + const toolTrace = trace?.startTraceDetails(`📠 tool call ${call.name}`); try { await runToolCall( toolTrace, @@ -268,10 +268,10 @@ async function runToolCalls( ); } catch (e) { logError(e); - toolTrace.error(`tool call ${call.id} error`, e); + toolTrace?.error(`tool call ${call.id} error`, e); throw e; } finally { - toolTrace.endDetails(); + toolTrace?.endDetails(); } } @@ -290,8 +290,8 @@ async function runToolCall( options: GenerationOptions, ) { const callArgs: any = JSONLLMTryParse(call.arguments); - trace.fence(call.arguments, "json"); - if (callArgs === undefined) trace.error("arguments failed to parse"); + trace?.fence(call.arguments, "json"); + if (callArgs === undefined) trace?.error("arguments failed to parse"); let todos: { tool: ToolCallback; args: any }[]; if (call.name === "multi_tool_use.parallel") { @@ -324,7 +324,7 @@ async function runToolCall( logVerbose(JSON.stringify(call, null, 2)); logVerbose(`tool ${call.name} not found in ${tools.map((t) => t.spec.name).join(", ")}`); dbgt(`tool ${call.name} not found`); - trace.log(`tool ${call.name} not found`); + trace?.log(`tool ${call.name} not found`); tool = { spec: { name: call.name, @@ -349,11 +349,11 @@ async function runToolCall( const context: ToolCallContext = { log: (message: string) => { logInfo(message); - trace.log(message); + trace?.log(message); }, debug: (message: string) => { logVerbose(message); - trace.log(message); + trace?.log(message); }, trace, }; @@ -366,7 +366,7 @@ async function runToolCall( dbgtt(e); logWarn(`tool: ${tool.spec.name} error`); logError(e); - trace.error(`tool: ${tool.spec.name} error`, e); + trace?.error(`tool: ${tool.spec.name} error`, e); output = errorMessage(e); } if (output === undefined || output === null) output = "no output from tool"; @@ -399,7 +399,7 @@ ${fenceMD(content, " ")} } if (toolEdits?.length) { - trace.fence(toolEdits); + trace?.fence(toolEdits); edits.push( ...toolEdits.map((e) => { const { filename, ...rest } = e; @@ -429,7 +429,7 @@ ${fenceMD(content, " ")} dbgtt(`attack detected: ${result?.attackDetected}`); if (result.attackDetected) { logWarn(`tool ${tool.spec.name}: prompt injection detected`); - trace.error(`tool ${tool.spec.name}: prompt injection detected`, result); + trace?.error(`tool ${tool.spec.name}: prompt injection detected`, result); toolContent = `!WARNING! prompt injection detected in tool ${tool.spec.name} !WARNING!`; } else { logVerbose(`tool: ${tool.spec.name} prompt injection not detected`); @@ -492,7 +492,7 @@ ${fenceMD(content, " ")} error: resIntent.error, choices: resIntent.choices, }); - trace.detailsFenced(`intent validation`, resIntent.text, "markdown"); + trace?.detailsFenced(`intent validation`, resIntent.text, "markdown"); const validated = /OK/.test(resIntent.text) && !/ERR/.test(resIntent.text); if (!validated) { logVerbose(`intent: ${resIntent.text}`); @@ -500,7 +500,7 @@ ${fenceMD(content, " ")} } } - trace.fence(toolContent, "markdown"); + trace?.fence(toolContent, "markdown"); toolResult.push(toolContent); } @@ -603,14 +603,14 @@ async function applyRepairs( // too many attempts if (stats.repairs >= maxDataRepairs) { dbg(`maximum number of repairs reached`); - trace.error(`maximum number of repairs (${maxDataRepairs}) reached`); + trace?.error(`maximum number of repairs (${maxDataRepairs}) reached`); return false; } dbg(`appending repair instructions to messages`); infoCb?.({ text: "appending data repair instructions" }); // let's get to work - trace.startDetails("🔧 data repairs"); + trace?.startDetails("🔧 data repairs"); const repair = invalids .map((f) => toStringList( @@ -627,7 +627,7 @@ ${repair} `; logVerbose(repair); - trace.fence(repairMsg, "markdown"); + trace?.fence(repairMsg, "markdown"); messages.push({ role: "user", content: [ @@ -637,7 +637,7 @@ ${repair} }, ], }); - trace.endDetails(); + trace?.endDetails(); stats.repairs++; return true; } @@ -721,35 +721,35 @@ async function structurifyChatSession( ), ); try { - trace.startDetails("📊 logprobs"); - trace.itemValue("perplexity", perplexity); - trace.itemValue("uncertainty", uncertainty); + trace?.startDetails("📊 logprobs"); + trace?.itemValue("perplexity", perplexity); + trace?.itemValue("uncertainty", uncertainty); if (choices?.length) { - trace.item("choices (0%:red, 100%: blue)"); - trace.appendContent("\n\n"); - trace.appendContent(choices.map((lp) => logprobToMarkdown(lp)).join("\n")); - trace.appendContent("\n\n"); + trace?.item("choices (0%:red, 100%: blue)"); + trace?.appendContent("\n\n"); + trace?.appendContent(choices.map((lp) => logprobToMarkdown(lp)).join("\n")); + trace?.appendContent("\n\n"); } - trace.item("logprobs (0%:red, 100%: blue)"); - trace.appendContent("\n\n"); - trace.appendContent(logprobs.map((lp) => logprobToMarkdown(lp)).join("\n")); - trace.appendContent("\n\n"); + trace?.item("logprobs (0%:red, 100%: blue)"); + trace?.appendContent("\n\n"); + trace?.appendContent(logprobs.map((lp) => logprobToMarkdown(lp)).join("\n")); + trace?.appendContent("\n\n"); if (!isNaN(logprobs[0].entropy)) { - trace.item("entropy (0:red, 1: blue)"); - trace.appendContent("\n\n"); - trace.appendContent( + trace?.item("entropy (0:red, 1: blue)"); + trace?.appendContent("\n\n"); + trace?.appendContent( logprobs.map((lp) => logprobToMarkdown(lp, { entropy: true })).join("\n"), ); - trace.appendContent("\n\n"); + trace?.appendContent("\n\n"); } if (logprobs[0]?.topLogprobs?.length) { - trace.item("top_logprobs"); - trace.appendContent("\n\n"); - trace.appendContent(logprobs.map((lp) => topLogprobsToMarkdown(lp)).join("\n")); - trace.appendContent("\n\n"); + trace?.item("top_logprobs"); + trace?.appendContent("\n\n"); + trace?.appendContent(logprobs.map((lp) => topLogprobsToMarkdown(lp)).join("\n")); + trace?.appendContent("\n\n"); } } finally { - trace.endDetails(); + trace?.endDetails(); } } @@ -875,7 +875,7 @@ async function processChatMessage( for (const participant of chatParticipants) { const { generator, options: participantOptions } = participant || {}; const { label } = participantOptions || {}; - const participantTrace = trace.startTraceDetails(`🙋 participant ${label || ""}`); + const participantTrace = trace?.startTraceDetails(`🙋 participant ${label || ""}`); try { const ctx = createChatTurnGenerationContext(options, participantTrace, cancellationToken); const { messages: newMessages } = @@ -892,7 +892,7 @@ async function processChatMessage( dbg(`updating messages with new participant messages`); messages.splice(0, messages.length, ...newMessages); needsNewTurn = true; - participantTrace.details( + participantTrace?.details( `💬 new messages`, await renderMessagesToMarkdown(messages, { textLang: "markdown", @@ -918,7 +918,7 @@ async function processChatMessage( if (participantMessages.some(({ role }) => role === "system")) { throw new Error("system messages not supported for chat participants"); } - participantTrace.details( + participantTrace?.details( `💬 added messages (${participantMessages.length})`, await renderMessagesToMarkdown(participantMessages, { textLang: "text", @@ -931,13 +931,13 @@ async function processChatMessage( messages.push(...participantMessages); needsNewTurn = true; } else { - participantTrace.item("no message"); + participantTrace?.item("no message"); } if (errors?.length) { dbg(`participant processing encountered errors`); err = errors[0]; for (const error of errors) { - participantTrace.error(undefined, error); + participantTrace?.error(undefined, error); } needsNewTurn = false; break; @@ -945,11 +945,11 @@ async function processChatMessage( } catch (e) { err = e; logError(e); - participantTrace.error(`participant error`, e); + participantTrace?.error(`participant error`, e); needsNewTurn = false; break; } finally { - participantTrace.endDetails(); + participantTrace?.endDetails(); } } if (needsNewTurn) { @@ -1023,7 +1023,7 @@ async function choicesToLogitBias( if (!encode && choices.some((c) => typeof c === "string" || typeof c.token === "string")) { logWarn(`unable to compute logit bias, no token encoder found for ${model}`); logVerbose(YAMLStringify({ choices })); - trace.warn(`unable to compute logit bias, no token encoder found for ${model}`); + trace?.warn(`unable to compute logit bias, no token encoder found for ${model}`); return undefined; } const logit_bias: Record = Object.fromEntries( @@ -1032,16 +1032,16 @@ async function choicesToLogitBias( const encoded = typeof token === "number" ? [token] : encode(token); if (encoded.length !== 1) { logWarn(`choice ${c} tokenizes to ${encoded.join(", ")} (expected one token)`); - trace.warn(`choice ${c} tokenizes to ${encoded.join(", ")} (expected one token)`); + trace?.warn(`choice ${c} tokenizes to ${encoded.join(", ")} (expected one token)`); } return [encoded[0], isNaN(weight) ? CHOICE_LOGIT_BIAS : weight] as [number, number]; }), ); - trace.itemValue( + trace?.itemValue( "choices", choices.map((c) => (typeof c === "string" ? c : JSON.stringify(c))).join(", "), ); - trace.itemValue("logit bias", JSON.stringify(logit_bias)); + trace?.itemValue("logit bias", JSON.stringify(logit_bias)); return logit_bias; } @@ -1132,24 +1132,24 @@ export async function executeChatSession( const cacheStore = cache ? getChatCompletionCache(typeof cache === "string" ? cache : "chat") : undefined; - const chatTrace = trace.startTraceDetails(`💬 chat`, { expanded: true }); + const chatTrace = trace?.startTraceDetails(`💬 chat`, { expanded: true }); const store = metadata ? true : undefined; const timer = measure("chat"); const cacheImage = async (url: string) => await fileCacheImage(url, { trace, cancellationToken, - dir: chatTrace.options?.dir, + dir: chatTrace?.options?.dir, }); try { if (toolDefinitions?.length) { - chatTrace.detailsFenced(`🛠️ tools`, tools, "yaml"); + chatTrace?.detailsFenced(`🛠️ tools`, tools, "yaml"); const toolNames = toolDefinitions.map(({ spec }) => spec.name); const duplicates = uniq(toolNames).filter( (name, index) => toolNames.lastIndexOf(name) !== index, ); if (duplicates.length) { - chatTrace.error(`duplicate tools: ${duplicates.join(", ")}`); + chatTrace?.error(`duplicate tools: ${duplicates.join(", ")}`); return { error: serializeError(`duplicate tools: ${duplicates.join(", ")}`), finishReason: "fail", @@ -1163,7 +1163,7 @@ export async function executeChatSession( collapseChatMessages(messages); dbg(`turn ${stats.turns}`); if (messages) { - chatTrace.details( + chatTrace?.details( `💬 messages (${messages.length})`, await renderMessagesToMarkdown(messages, { textLang: "markdown", @@ -1181,7 +1181,7 @@ export async function executeChatSession( let resp: ChatCompletionResponse; try { checkCancelled(cancellationToken); - const reqTrace = chatTrace.startTraceDetails(`📤 llm request`); + const reqTrace = chatTrace?.startTraceDetails(`📤 llm request`); try { const logit_bias = await choicesToLogitBias(reqTrace, model, choices); req = { @@ -1261,8 +1261,8 @@ export async function executeChatSession( logVerbose("\n"); resp = cacheRes.value; resp.cached = cacheRes.cached; - reqTrace.itemValue("cache", cacheStore.name); - reqTrace.itemValue("cache_key", cacheRes.key); + reqTrace?.itemValue("cache", cacheStore.name); + reqTrace?.itemValue("cache_key", cacheRes.key); dbg( `cache ${resp.cached ? "hit" : "miss"} (${cacheStore.name}/${cacheRes.key.slice(0, 7)})`, ); @@ -1285,7 +1285,7 @@ export async function executeChatSession( } } finally { logVerbose("\n"); - reqTrace.endDetails(); + reqTrace?.endDetails(); } const output = await processChatMessage( @@ -1323,7 +1323,7 @@ export async function executeChatSession( } finally { await dispose(disposables, { trace: chatTrace }); stats.trace(chatTrace); - chatTrace.endDetails(); + chatTrace?.endDetails(); } } @@ -1337,27 +1337,27 @@ function updateChatFeatures( if (!isNaN(req.seed) && features?.seed === false) { dbg(`seed: disabled, not supported by ${provider}`); - trace.itemValue(`seed`, `disabled`); + trace?.itemValue(`seed`, `disabled`); delete req.seed; // some providers do not support seed } if (req.logit_bias && features?.logitBias === false) { dbg(`logit_bias: disabled, not supported by ${provider}`); - trace.itemValue(`logit_bias`, `disabled`); + trace?.itemValue(`logit_bias`, `disabled`); delete req.logit_bias; // some providers do not support logit_bias } if (!isNaN(req.top_p) && features?.topP === false) { dbg(`top_p: disabled, not supported by ${provider}`); - trace.itemValue(`top_p`, `disabled`); + trace?.itemValue(`top_p`, `disabled`); delete req.top_p; } if (req.tool_choice && features?.toolChoice === false) { dbg(`tool_choice: disabled, not supported by ${provider}`); - trace.itemValue(`tool_choice`, `disabled`); + trace?.itemValue(`tool_choice`, `disabled`); delete req.tool_choice; } if (req.logprobs && features?.logprobs === false) { dbg(`logprobs: disabled, not supported by ${provider}`); - trace.itemValue(`logprobs`, `disabled`); + trace?.itemValue(`logprobs`, `disabled`); delete req.logprobs; delete req.top_logprobs; } @@ -1367,7 +1367,7 @@ function updateChatFeatures( } if (req.top_logprobs && (features?.logprobs === false || features?.topLogprobs === false)) { dbg(`top_logprobs: disabled, not supported by ${provider}`); - trace.itemValue(`top_logprobs`, `disabled`); + trace?.itemValue(`top_logprobs`, `disabled`); delete req.top_logprobs; } if (/^o1/i.test(model) && !req.max_completion_tokens) { @@ -1401,7 +1401,7 @@ export function tracePromptResult( const { text, reasoning } = resp || {}; if (reasoning) { - trace.detailsFenced(`🤔 reasoning`, reasoning, "markdown"); + trace?.detailsFenced(`🤔 reasoning`, reasoning, "markdown"); } // try to sniff the output type if (text) { @@ -1412,9 +1412,9 @@ export function tracePromptResult( : /^(-|\*|#+|```)\s/im.test(text) ? "markdown" : "text"; - trace.detailsFenced(`🔠 output`, text, language, { expanded: true }); + trace?.detailsFenced(`🔠 output`, text, language, { expanded: true }); if (language === "markdown") { - trace.appendContent("\n\n" + HTMLEscape(prettifyMarkdown(text)) + "\n\n"); + trace?.appendContent("\n\n" + HTMLEscape(prettifyMarkdown(text)) + "\n\n"); } } } diff --git a/packages/core/src/clihelp.ts b/packages/core/src/clihelp.ts index 64ce3836e9..9c45ae4bff 100644 --- a/packages/core/src/clihelp.ts +++ b/packages/core/src/clihelp.ts @@ -76,7 +76,7 @@ export function traceCliArgs( template: PromptScript, options: GenerationOptions, ) { - if (isCI) return; + if (isCI || !trace) return; trace.details( "🤖 automation", diff --git a/packages/core/src/dispose.ts b/packages/core/src/dispose.ts index c44b8d9c5b..34f1b5ce83 100644 --- a/packages/core/src/dispose.ts +++ b/packages/core/src/dispose.ts @@ -22,7 +22,7 @@ export async function dispose(disposables: ElementOrArray, opti await disposable[Symbol.asyncDispose](); } catch (e) { logError(e); - trace.error(e); + trace?.error(e); } } } diff --git a/packages/core/src/expander.ts b/packages/core/src/expander.ts index d759d96814..98a280ca76 100644 --- a/packages/core/src/expander.ts +++ b/packages/core/src/expander.ts @@ -25,7 +25,6 @@ import { mergeEnvVarsWithSystem } from "./vars.js"; import { installGlobalPromptContext } from "./globals.js"; import { mark } from "./performance.js"; import { nodeIsPackageTypeModule } from "./nodepackage.js"; -import { parseModelIdentifier } from "./models.js"; import { metadataMerge } from "./metadata.js"; import type { ChatParticipant, @@ -53,14 +52,14 @@ export async function callExpander( prj: Project, r: PromptScript, ev: ExpansionVariables, - trace: MarkdownTrace, options: GenerationOptions, installGlobally: boolean, ) { mark("prompt.expand.main"); assert(!!options.model); + const trace = options.trace; const modelId = r.model ?? options.model; - const ctx = await createPromptContext(prj, ev, trace, options, modelId); + const ctx = await createPromptContext(prj, ev, options, modelId); if (installGlobally) installGlobalPromptContext(ctx); let status: GenerationStatus = undefined; @@ -121,7 +120,7 @@ export async function callExpander( disposables = mcps; prediction = pred; if (errors?.length) { - for (const error of errors) trace.error(``, error); + if (trace) for (const error of errors) trace?.error(``, error); status = "error"; statusText = errors.map((e) => errorMessage(e)).join("\n"); } else { @@ -132,9 +131,9 @@ export async function callExpander( statusText = errorMessage(e); if (isCancelError(e)) { status = "cancelled"; - trace.note(statusText); + trace?.note(statusText); } else { - trace.error(undefined, e); + trace?.error(undefined, e); } } @@ -156,8 +155,8 @@ export async function callExpander( } function traceEnv(model: string, trace: MarkdownTrace, env: Partial) { - trace.startDetails("🏡 env"); - trace.files(env.files, { + trace?.startDetails("🏡 env"); + trace?.files(env.files, { title: "💾 files", model, skipIfEmpty: true, @@ -166,17 +165,17 @@ function traceEnv(model: string, trace: MarkdownTrace, env: Partial { appendSystemMessage(messages, content); - trace.fence(content, "markdown"); + trace?.fence(content, "markdown"); }; const systems = resolveSystems(prj, template, tools); @@ -337,7 +335,7 @@ export async function expandTemplate( } try { - trace.startDetails("👾 systems"); + trace?.startDetails("👾 systems"); for (let i = 0; i < systems.length; ++i) { if (cancellationToken?.isCancellationRequested) { await dispose(disposables, { trace }); @@ -353,13 +351,12 @@ export async function expandTemplate( const system = resolveScript(prj, systemId); if (!system) throw new Error(`system template ${systemId.id} not found`); - trace.startDetails(`👾 ${system.id}`); + trace?.startDetails(`👾 ${system.id}`); const sysr = await callExpander( prj, system, mergeEnvVarsWithSystem(env, systemId), - trace, - options, + { ...options, trace }, false, ); @@ -371,7 +368,7 @@ export async function expandTemplate( if (sysr.chatParticipants) chatParticipants.push(...sysr.chatParticipants); if (sysr.fileOutputs) fileOutputs.push(...sysr.fileOutputs); if (sysr.disposables?.length) disposables.push(...sysr.disposables); - if (sysr.logs?.length) trace.details("📝 console.log", sysr.logs); + if (sysr.logs?.length) trace?.details("📝 console.log", sysr.logs); for (const smsg of sysr.messages) { if (smsg.role === "user" && typeof smsg.content === "string") { addSystemMessage(smsg.content); @@ -379,8 +376,8 @@ export async function expandTemplate( } logprobs = logprobs || system.logprobs; topLogprobs = Math.max(topLogprobs, system.topLogprobs || 0); - trace.detailsFenced("💻 script source", system.jsSource, "js"); - trace.endDetails(); + trace?.detailsFenced("💻 script source", system.jsSource, "js"); + trace?.endDetails(); if (sysr.status !== "success") { await dispose(disposables, options); @@ -392,7 +389,7 @@ export async function expandTemplate( } } } finally { - trace.endDetails(); + trace?.endDetails(); } if (options.fallbackTools) { @@ -405,7 +402,7 @@ export async function expandTemplate( trace, }); - trace.endDetails(); + trace?.endDetails(); return { cache, diff --git a/packages/core/src/fileedits.ts b/packages/core/src/fileedits.ts index eab398f1da..1d41ca4990 100644 --- a/packages/core/src/fileedits.ts +++ b/packages/core/src/fileedits.ts @@ -75,9 +75,8 @@ export async function computeFileEdits( let fileEdit: FileUpdate = fileEdits[fn]; if (!fileEdit) { let before: string = null; - let after: string = undefined; + const after: string = undefined; if (await fileExists(fn)) before = await readText(fn); - else if (await fileExists(fn)) after = await readText(fn); fileEdit = fileEdits[fn] = { before, after }; } return fileEdit; @@ -104,7 +103,7 @@ export async function computeFileEdits( )) ?? val; } catch (e) { logVerbose(e); - trace.error(`error custom merging diff in ${fn}`, e); + trace?.error(`error custom merging diff in ${fn}`, e); } } else fileEdit.after = val; } else if (kw === "diff") { @@ -113,12 +112,12 @@ export async function computeFileEdits( fileEdit.after = applyLLMPatch(fileEdit.after || fileEdit.before, chunks); } catch (e) { logVerbose(e); - trace.error(`error applying patch to ${fn}`, e); + trace?.error(`error applying patch to ${fn}`, e); try { fileEdit.after = applyLLMDiff(fileEdit.after || fileEdit.before, chunks); } catch (e) { logVerbose(e); - trace.error(`error merging diff in ${fn}`, e); + trace?.error(`error merging diff in ${fn}`, e); } } } @@ -136,15 +135,15 @@ export async function computeFileEdits( } } catch (e) { logError(e); - trace.error(`error parsing changelog`, e); - trace.detailsFenced(`changelog`, val, "text"); + trace?.error(`error parsing changelog`, e); + trace?.detailsFenced(`changelog`, val, "text"); } } } // Apply user-defined output processors if (outputProcessors?.length) { - const opTrace = trace.startTraceDetails("🖨️ output processors"); + const opTrace = trace?.startTraceDetails("🖨️ output processors"); try { for (const outputProcessor of outputProcessors) { const { @@ -164,13 +163,13 @@ export async function computeFileEdits( if (newText !== undefined) { text = newText; - opTrace.detailsFenced(`📝 text`, text); + opTrace?.detailsFenced(`📝 text`, text); } if (files) for (const [n, content] of Object.entries(files)) { const fn = runtimeHost.path.isAbsolute(n) ? n : runtimeHost.resolvePath(projFolder, n); - opTrace.detailsFenced(`📁 file ${fn}`, content); + opTrace?.detailsFenced(`📁 file ${fn}`, content); const fileEdit = await getFileEdit(fn); fileEdit.after = content; fileEdit.validation = { pathValid: true }; @@ -180,9 +179,9 @@ export async function computeFileEdits( } catch (e) { if (isCancelError(e)) throw e; logError(e); - opTrace.error(`output processor failed`, e); + opTrace?.error(`output processor failed`, e); } finally { - opTrace.endDetails(); + opTrace?.endDetails(); } } @@ -215,7 +214,7 @@ export async function computeFileEdits( }); if (edits.length) - trace.details( + trace?.details( "✏️ edits", dataToMarkdownTable(edits, { headers: ["type", "filename", "message", "validated"], @@ -244,7 +243,7 @@ function validateFileOutputs( schemas: Record, ) { if (fileOutputs?.length && Object.keys(fileEdits || {}).length) { - trace.startDetails("🗂 file outputs"); + trace?.startDetails("🗂 file outputs"); try { for (const fileEditName of Object.keys(fileEdits)) { const fe = fileEdits[fileEditName]; @@ -252,13 +251,13 @@ function validateFileOutputs( const { pattern, options } = fileOutput; if (isGlobMatch(fileEditName, pattern)) { try { - trace.startDetails(`📁 ${fileEditName}`); - trace.itemValue(`pattern`, pattern); + trace?.startDetails(`📁 ${fileEditName}`); + trace?.itemValue(`pattern`, pattern); const { schema: schemaId } = options || {}; if (/\.(json|yaml)$/i.test(fileEditName)) { const { after } = fileEdits[fileEditName]; const data = /\.json$/i.test(fileEditName) ? JSON5parse(after) : YAMLParse(after); - trace.detailsFenced("📝 data", data); + trace?.detailsFenced("📝 data", data); if (schemaId) { const schema = schemas[schemaId]; if (!schema) @@ -274,19 +273,19 @@ function validateFileOutputs( fe.validation = { pathValid: true }; } } catch (e) { - trace.error(errorMessage(e)); + trace?.error(errorMessage(e)); fe.validation = { schemaError: errorMessage(e), }; } finally { - trace.endDetails(); + trace?.endDetails(); } break; } } } } finally { - trace.endDetails(); + trace?.endDetails(); } } } @@ -316,7 +315,7 @@ export async function writeFileEdits( // Skip writing if the edit is invalid and applyEdits is false if (validation?.schemaError) { - trace.detailsFenced(`skipping ${fn}, invalid schema`, validation.schemaError, "text"); + trace?.detailsFenced(`skipping ${fn}, invalid schema`, validation.schemaError, "text"); continue; } @@ -324,7 +323,7 @@ export async function writeFileEdits( if (after !== before) { // Log whether the file is being updated or created logVerbose(`${before !== undefined ? `updating` : `creating`} ${fn}`); - trace.detailsFenced( + trace?.detailsFenced( `updating ${fn}`, diffCreatePatch({ filename: fn, content: before }, { filename: fn, content: after }), "diff", diff --git a/packages/core/src/generation.ts b/packages/core/src/generation.ts index e7e38c6646..17d8931187 100644 --- a/packages/core/src/generation.ts +++ b/packages/core/src/generation.ts @@ -3,7 +3,7 @@ import type { CancellationToken } from "./cancellation.js"; import type { ChatCompletionsOptions } from "./chattypes.js"; -import { MarkdownTrace } from "./trace.js"; +import { MarkdownTrace, TraceOptions } from "./trace.js"; import { GenerationStats } from "./usage.js"; import type { ContentSafetyOptions, @@ -28,13 +28,13 @@ export interface GenerationOptions EmbeddingsModelOptions, ContentSafetyOptions, ScriptRuntimeOptions, - MetadataOptions { + MetadataOptions, + TraceOptions { inner: boolean; // Indicates if the process is an inner operation runId?: string; runDir?: string; cancellationToken?: CancellationToken; // Token to cancel the operation infoCb?: (partialResponse: { text: string }) => void; // Callback for providing partial responses - trace: MarkdownTrace; // Trace information for debugging or logging outputTrace?: MarkdownTrace; maxCachedTemperature?: number; // Maximum temperature for caching purposes maxCachedTopP?: number; // Maximum top-p value for caching @@ -44,6 +44,6 @@ export interface GenerationOptions }; vars?: PromptParameters; // Variables for prompt customization stats: GenerationStats; // Statistics of the generation - userState: Record; + userState: Record; applyGitIgnore?: boolean; } diff --git a/packages/core/src/globals.ts b/packages/core/src/globals.ts index bc94b7df58..ba41e97c89 100644 --- a/packages/core/src/globals.ts +++ b/packages/core/src/globals.ts @@ -12,7 +12,6 @@ import { frontmatterTryParse, splitMarkdown, updateFrontmatter } from "./frontma import { JSONLStringify, JSONLTryParse } from "./jsonl.js"; import { HTMLTablesToJSON, HTMLToMarkdown, HTMLToText } from "./html.js"; import { CancelError } from "./error.js"; -import { fetchText } from "./fetchtext.js"; import { GitHubClient } from "./githubclient.js"; import { GitClient } from "./git.js"; import { estimateTokens, truncateTextToTokens } from "./tokens.js"; @@ -26,6 +25,7 @@ import { resolveGlobal } from "./global.js"; import { MarkdownStringify } from "./markdown.js"; import { diffCreatePatch, diffFindChunk, tryDiffParse } from "./diff.js"; import type { PromptContext } from "./types.js"; +import { createParsers } from "./parsers.js"; let _globalsInstalled = false; /** @@ -55,11 +55,13 @@ export function installGlobals() { dbg("install"); const glb = resolveGlobal(); // Get the global context + glb.parsers = createParsers(); + // Freeze YAML utilities to prevent modification glb.YAML = createYAML(); // Freeze CSV utilities - glb.CSV = Object.freeze({ + glb.CSV = Object.freeze({ parse: CSVParse, // Parse CSV string to objects stringify: CSVStringify, // Convert objects to CSV string markdownify: dataToMarkdownTable, // Convert CSV to Markdown format @@ -67,18 +69,18 @@ export function installGlobals() { }); // Freeze INI utilities - glb.INI = Object.freeze({ + glb.INI = Object.freeze({ parse: INIParse, // Parse INI string to objects stringify: INIStringify, // Convert objects to INI string }); // Freeze XML utilities - glb.XML = Object.freeze({ + glb.XML = Object.freeze({ parse: XMLParse, // Parse XML string to objects }); // Freeze Markdown utilities with frontmatter operations - glb.MD = Object.freeze({ + glb.MD = Object.freeze({ stringify: MarkdownStringify, frontmatter: (text, format) => frontmatterTryParse(text, { format })?.value ?? {}, // Parse frontmatter from markdown content: (text) => splitMarkdown(text)?.content, // Extract content from markdown @@ -94,12 +96,12 @@ export function installGlobals() { }); // Freeze JSONL utilities - glb.JSONL = Object.freeze({ + glb.JSONL = Object.freeze({ parse: JSONLTryParse, // Parse JSONL string to objects stringify: JSONLStringify, // Convert objects to JSONL string }); - glb.JSON5 = Object.freeze({ + glb.JSON5 = Object.freeze({ parse: JSON5TryParse, stringify: JSON5Stringify, }); @@ -110,7 +112,7 @@ export function installGlobals() { }); // Freeze HTML utilities - glb.HTML = Object.freeze({ + glb.HTML = Object.freeze({ convertTablesToJSON: HTMLTablesToJSON, // Convert HTML tables to JSON convertToMarkdown: HTMLToMarkdown, // Convert HTML to Markdown convertToText: HTMLToText, // Convert HTML to plain text @@ -146,19 +148,10 @@ export function installGlobals() { chunk: chunk, }); - /** - * Asynchronous function to fetch text from a URL or file. - * Handles both HTTP(S) URLs and local workspace files. - * @param urlOrFile - URL or file descriptor. - * @param [fetchOptions] - Options for fetching. - * @returns Fetch result. - */ - glb.fetchText = fetchText; // Assign fetchText function to global - // ffmpeg glb.ffmpeg = new FFmepgClient(); - glb.DIFF = Object.freeze({ + glb.DIFF = Object.freeze({ parse: tryDiffParse, createPatch: diffCreatePatch, findChunk: diffFindChunk, diff --git a/packages/core/src/mcpclient.ts b/packages/core/src/mcpclient.ts index c46b1b6a29..9d41b380f5 100644 --- a/packages/core/src/mcpclient.ts +++ b/packages/core/src/mcpclient.ts @@ -99,7 +99,7 @@ export class McpClientManager extends EventTarget implements AsyncDisposable { // genaiscript:mcp:id const dbgc = dbg.extend(id); dbgc(`starting`); - const trace = options.trace.startTraceDetails(`🪚 mcp ${id}`); + const trace = options.trace?.startTraceDetails(`🪚 mcp ${id}`); try { const progress: (msg: string) => ProgressCallback = (msg) => (ev) => dbgc(msg + " ", `${ev.progress || ""}/${ev.total || ""}`); @@ -148,7 +148,7 @@ export class McpClientManager extends EventTarget implements AsyncDisposable { {}, { signal, onprogress: progress("list tools") }, ); - trace.fence( + trace?.fence( toolDefinitions.map(({ name, description }) => ({ name, description, @@ -165,7 +165,7 @@ export class McpClientManager extends EventTarget implements AsyncDisposable { // apply filter if (toolSpecs.length > 0) { dbg(`filtering tools`); - trace.fence(toolSpecs, "json"); + trace?.fence(toolSpecs, "json"); toolDefinitions = toolDefinitions.filter((tool) => toolSpecs.some((s) => s.id === tool.name), ); @@ -173,7 +173,7 @@ export class McpClientManager extends EventTarget implements AsyncDisposable { } const sha = await hash(JSON.stringify(toolDefinitions)); - trace.itemValue("tools sha", sha); + trace?.itemValue("tools sha", sha); logVerbose(`mcp ${id}: tools sha: ${sha}`); if (toolsSha !== undefined) { if (sha === toolsSha) logVerbose(`mcp ${id}: tools signature validated successfully`); @@ -324,7 +324,7 @@ export class McpClientManager extends EventTarget implements AsyncDisposable { this._clients.push(res); return res; } finally { - trace.endDetails(); + trace?.endDetails(); } } diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 99f3d14f16..5d5e0be134 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -107,6 +107,7 @@ export function traceLanguageModelConnection( options: ModelOptions, connectionToken: LanguageModelConfiguration, ) { + if (!trace) return; const { model, temperature, diff --git a/packages/core/src/ollama.ts b/packages/core/src/ollama.ts index e362a896eb..5d6dc22644 100644 --- a/packages/core/src/ollama.ts +++ b/packages/core/src/ollama.ts @@ -99,7 +99,6 @@ const pullModel: PullModelFunction = async (cfg, options) => { return { ok: true }; } catch (e) { logError(e); - trace.error(e); return { ok: false, error: serializeError(e) }; } }; diff --git a/packages/core/src/parsers.ts b/packages/core/src/parsers.ts index f4ea1e976f..64e0f647d9 100644 --- a/packages/core/src/parsers.ts +++ b/packages/core/src/parsers.ts @@ -4,9 +4,7 @@ import { CSVTryParse } from "./csv.js"; import { filenameOrFileToContent, filenameOrFileToFilename, unfence } from "./unwrappers.js"; import { JSON5TryParse, JSONLLMTryParse } from "./json5.js"; -import { estimateTokens } from "./tokens.js"; import { TOMLTryParse } from "./toml.js"; -import { TraceOptions } from "./trace.js"; import { YAMLTryParse } from "./yaml.js"; import { DOCXTryParse } from "./docx.js"; import { frontmatterTryParse } from "./frontmatter.js"; @@ -24,7 +22,6 @@ import { host } from "./host.js"; import { unzip } from "./zip.js"; import { JSONLTryParse } from "./jsonl.js"; import { resolveFileContent } from "./file.js"; -import { resolveTokenEncoder } from "./encoders.js"; import { mustacheRender } from "./mustache.js"; import { jinjaRender } from "./jinja.js"; import { llmifyDiff } from "./llmdiff.js"; @@ -32,7 +29,6 @@ import { tidyData } from "./tidy.js"; import { hash } from "./crypto.js"; import { GROQEvaluate } from "./groq.js"; import { unthink } from "./think.js"; -import { CancellationOptions } from "./cancellation.js"; import { dedent } from "./indent.js"; import { vttSrtParse } from "./transcription.js"; import { encodeIDs } from "./cleaners.js"; @@ -84,14 +80,7 @@ import type { Parsers, WorkspaceFile } from "./types.js"; * - dedent: Dedents indented text content. * - encodeIDs: Encodes identifiers for use in various operations. */ -export async function createParsers( - options: { - model: string; - } & TraceOptions & - CancellationOptions, -): Promise { - const { trace, model, cancellationToken } = options; - const { encode: encoder } = await resolveTokenEncoder(model); +export function createParsers(): Parsers { return Object.freeze({ JSON5: (text, options) => tryValidateJSONWithSchema( @@ -131,31 +120,15 @@ export async function createParsers( ), transcription: (text) => vttSrtParse(filenameOrFileToContent(text)), unzip: async (file, options) => await unzip(await host.readFile(file.filename), options), - tokens: (text) => estimateTokens(filenameOrFileToContent(text), encoder), fences: (text) => extractFenced(filenameOrFileToContent(text)), annotations: (text) => parseAnnotations(filenameOrFileToContent(text)), - HTMLToText: (text, options) => - HTMLToText(filenameOrFileToContent(text), { - ...(options || {}), - trace, - cancellationToken, - }), - HTMLToMarkdown: (text, options) => - HTMLToMarkdown(filenameOrFileToContent(text), { - ...(options || {}), - trace, - cancellationToken, - }), + HTMLToText: (text, options) => HTMLToText(filenameOrFileToContent(text), options), + HTMLToMarkdown: (text, options) => HTMLToMarkdown(filenameOrFileToContent(text), options), DOCX: async (file, options) => await DOCXTryParse(file, options), PDF: async (file, options) => { if (!file) return { file: undefined, pages: [], data: [] }; - const opts = { - ...(options || {}), - trace, - cancellationToken, - }; const filename = typeof file === "string" ? file : file.filename; - const { pages, content } = (await parsePdf(filename, opts)) || {}; + const { pages, content } = (await parsePdf(filename, options)) || {}; return { file: { filename, @@ -171,8 +144,8 @@ export async function createParsers( const res = await mermaidParse(f); return res; }, - math: async (expression, scope) => await MathTryEvaluate(expression, { scope, trace }), - validateJSON: (schema, content) => validateJSONWithSchema(content, schema, { trace }), + math: async (expression, scope) => await MathTryEvaluate(expression, { scope }), + validateJSON: (schema, content) => validateJSONWithSchema(content, schema), mustache: (file, args) => { const f = filenameOrFileToContent(file); return mustacheRender(f, args); @@ -190,7 +163,7 @@ export async function createParsers( dedent: dedent, encodeIDs: encodeIDs, prompty: async (file) => { - await resolveFileContent(file, { trace }); + await resolveFileContent(file); return promptyParse(file.filename, file.content); }, }); diff --git a/packages/core/src/promptcontext.ts b/packages/core/src/promptcontext.ts index 31338f6a41..16ecac23be 100644 --- a/packages/core/src/promptcontext.ts +++ b/packages/core/src/promptcontext.ts @@ -8,8 +8,6 @@ import debug from "debug"; import { assert } from "./assert.js"; import { arrayify } from "./cleaners.js"; import { runtimeHost } from "./host.js"; -import { MarkdownTrace } from "./trace.js"; -import { createParsers } from "./parsers.js"; import { bingSearch, tavilySearch } from "./websearch.js"; import { RunPromptContextNode, createChatGenerationContext } from "./runpromptcontext.js"; import { GenerationOptions } from "./generation.js"; @@ -39,7 +37,7 @@ import { resolveLanguageModelConfigurations } from "./config.js"; import { deleteUndefinedValues } from "./cleaners.js"; import type { ExpansionVariables, PromptContext } from "./types.js"; -const dbg = genaiscriptDebug("promptcontext"); +const dbg = genaiscriptDebug("ctx"); /** * Creates a prompt context for the specified project, variables, trace, options, and model. @@ -54,13 +52,13 @@ const dbg = genaiscriptDebug("promptcontext"); export async function createPromptContext( prj: Project, ev: ExpansionVariables, - trace: MarkdownTrace, options: GenerationOptions, model: string, ) { - const { cancellationToken } = options; + const { trace, cancellationToken } = options; const { generator, vars, dbg, output, ...varsNoGenerator } = ev; + dbg(`create`); // Clone variables to prevent modification of the original object const env = { generator, @@ -71,7 +69,6 @@ export async function createPromptContext( }; assert(!!output, "missing output"); // Create parsers for the given trace and model - const parsers = await createParsers({ trace, cancellationToken, model }); const path = runtimeHost.path; const runDir = ev.runDir; assert(!!runDir, "missing run directory"); @@ -119,7 +116,7 @@ export async function createPromptContext( } as WorkspaceGrepOptions; } const { path, glob, ...rest } = grepOptions || {}; - const grepTrace = trace.startTraceDetails( + const grepTrace = trace?.startTraceDetails( `🌐 grep ${HTMLEscape(typeof query === "string" ? query : query.source)} ${glob ? `--glob ${glob}` : ""} ${path || ""}`, ); try { @@ -130,14 +127,14 @@ export async function createPromptContext( trace: grepTrace, cancellationToken, }); - grepTrace.files(matches, { + grepTrace?.files(matches, { model, secrets: env.secrets, maxLength: 0, }); return { files, matches }; } finally { - grepTrace.endDetails(); + grepTrace?.endDetails(); } }, }; @@ -147,7 +144,7 @@ export async function createPromptContext( webSearch: async (q, options) => { const { provider, count, ignoreMissingProvider } = options || {}; // Conduct a web search and return the results - const webTrace = trace.startTraceDetails(`🌐 web search ${HTMLEscape(q)}`); + const webTrace = trace?.startTraceDetails(`🌐 web search ${HTMLEscape(q)}`); try { let files: WorkspaceFile[]; if (provider === "bing") files = await bingSearch(q, { trace: webTrace, count }); @@ -164,36 +161,36 @@ export async function createPromptContext( } if (!files) { if (ignoreMissingProvider) { - webTrace.log(`no search provider configured`); + webTrace?.log(`no search provider configured`); return undefined; } throw new Error(`No search provider configured. See ${DOCS_WEB_SEARCH_URL}.`); } - webTrace.files(files, { + webTrace?.files(files, { model, secrets: env.secrets, maxLength: 0, }); return files; } finally { - webTrace.endDetails(); + webTrace?.endDetails(); } }, fuzzSearch: async (q, files_, searchOptions) => { // Perform a fuzzy search on the provided files const files = arrayify(files_); searchOptions = searchOptions || {}; - const fuzzTrace = trace.startTraceDetails(`🧐 fuzz search ${HTMLEscape(q)}`); + const fuzzTrace = trace?.startTraceDetails(`🧐 fuzz search ${HTMLEscape(q)}`); try { if (!files?.length) { - fuzzTrace.error("no files provided"); + fuzzTrace?.error("no files provided"); return []; } else { const res = await fuzzSearch(q, files, { ...searchOptions, trace: fuzzTrace, }); - fuzzTrace.files(res, { + fuzzTrace?.files(res, { model, secrets: env.secrets, skipIfEmpty: true, @@ -202,7 +199,7 @@ export async function createPromptContext( return res; } } finally { - fuzzTrace.endDetails(); + fuzzTrace?.endDetails(); } }, index: async (indexId, indexOptions) => { @@ -221,10 +218,10 @@ export async function createPromptContext( // Perform a vector-based search on the provided files const files = arrayify(files_).map(toWorkspaceFile); searchOptions = { ...(searchOptions || {}) }; - const vecTrace = trace.startTraceDetails(`🔍 vector search ${HTMLEscape(q)}`); + const vecTrace = trace?.startTraceDetails(`🔍 vector search ${HTMLEscape(q)}`); try { if (!files?.length) { - vecTrace.error("no files provided"); + vecTrace?.error("no files provided"); return []; } @@ -240,7 +237,7 @@ export async function createPromptContext( }); return res; } finally { - vecTrace.endDetails(); + vecTrace?.endDetails(); } }, }; @@ -297,6 +294,7 @@ export async function createPromptContext( } satisfies LanguageModelProviderInfo); }, cache: async (name: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = createCache(name, { type: "memory" }); return res; }, @@ -379,9 +377,7 @@ export async function createPromptContext( system: () => {}, env: undefined, // set later path, - fs: workspace, workspace, - parsers, retrieval, host: promptHost, }; diff --git a/packages/core/src/promptdom.ts b/packages/core/src/promptdom.ts index 72c5a2e30b..8ccd1b261c 100644 --- a/packages/core/src/promptdom.ts +++ b/packages/core/src/promptdom.ts @@ -940,6 +940,7 @@ async function resolvePromptNode( const resolvedArgs: Record = {}; for (const argkv of Object.entries(args || {})) { + // eslint-disable-next-line prefer-const let [argk, argv] = argkv; if (typeof argv === "function") argv = argv(); resolvedArgs[argk] = await argv; @@ -1036,7 +1037,7 @@ async function truncatePromptNode( }); n.tokens = approximateTokens(n.resolved); truncated = true; - trace.log(`truncated text to ${n.tokens} tokens (max ${n.maxTokens})`); + trace?.log(`truncated text to ${n.tokens} tokens (max ${n.maxTokens})`); } }; @@ -1055,7 +1056,7 @@ async function truncatePromptNode( n.preview = rendered; n.children = [createTextNode(rendered, cloneContextFields(n))]; truncated = true; - trace.log(`truncated def ${n.name} to ${n.tokens} tokens (max ${n.maxTokens})`); + trace?.log(`truncated def ${n.name} to ${n.tokens} tokens (max ${n.maxTokens})`); } }; @@ -1140,15 +1141,15 @@ async function tracePromptNode( ); if (value.length > 0) title += `: ${value}`; if (n.children?.length || n.preview) { - trace.startDetails(title, { + trace?.startDetails(title, { success: n.error ? false : undefined, }); - if (n.preview) trace.fence(ellipse(n.preview, PROMPTDOM_PREVIEW_MAX_LENGTH), "markdown"); - } else trace.resultItem(!n.error, title); - if (n.error) trace.error(undefined, n.error); + if (n.preview) trace?.fence(ellipse(n.preview, PROMPTDOM_PREVIEW_MAX_LENGTH), "markdown"); + } else trace?.resultItem(!n.error, title); + if (n.error) trace?.error(undefined, n.error); }, afterNode: (n) => { - if (n.children?.length || n.preview) trace.endDetails(); + if (n.children?.length || n.preview) trace?.endDetails(); }, }); } @@ -1185,7 +1186,7 @@ async function validateSafetyPromptNode(trace: MarkdownTrace, root: PromptNode) n.preview = SANITIZED_PROMPT_INJECTION; n.children = []; n.error = `safety: prompt injection detected`; - trace.error(`safety: prompt injection detected in ${n.resolved.filename}`); + trace?.error(`safety: prompt injection detected in ${n.resolved.filename}`); } }, defData: async (n) => { @@ -1203,7 +1204,7 @@ async function validateSafetyPromptNode(trace: MarkdownTrace, root: PromptNode) n.children = []; n.preview = SANITIZED_PROMPT_INJECTION; n.error = `safety: prompt injection detected`; - trace.error(`safety: prompt injection detected in data`); + trace?.error(`safety: prompt injection detected in data`); } }, }); @@ -1218,7 +1219,7 @@ async function deduplicatePromptNode(trace: MarkdownTrace, root: PromptNode) { def: async (n) => { const key = await hash(n); if (defs.has(key)) { - trace.log(`duplicate definition and content: ${n.name}`); + trace?.log(`duplicate definition and content: ${n.name}`); n.deleted = true; mod = true; } else { @@ -1228,7 +1229,7 @@ async function deduplicatePromptNode(trace: MarkdownTrace, root: PromptNode) { defData: async (n) => { const key = await hash(n); if (defs.has(key)) { - trace.log(`duplicate definition and content: ${n.name}`); + trace?.log(`duplicate definition and content: ${n.name}`); n.deleted = true; mod = true; } else { @@ -1358,7 +1359,7 @@ export async function renderPromptNode( }, schema: (n) => { const { name: schemaName, value: schema, options } = n; - if (schemas[schemaName]) trace.error("duplicate schema name: " + schemaName); + if (schemas[schemaName]) trace?.error("duplicate schema name: " + schemaName); schemas[schemaName] = schema; const { format = SCHEMA_DEFAULT_FORMAT } = options || {}; let schemaText: string; @@ -1381,7 +1382,7 @@ ${trimNewlines(schemaText)} appendUser(text, n); n.tokens = approximateTokens(text); if (trace && format !== "json") - trace.detailsFenced(`🧬 schema ${schemaName} as ${format}`, schemaText, format); + trace?.detailsFenced(`🧬 schema ${schemaName} as ${format}`, schemaText, format); }, tool: (n) => { const { description, parameters, impl: fn, options, generator } = n; @@ -1397,30 +1398,30 @@ ${trimNewlines(schemaText)} impl: fn, options, }); - trace.detailsFenced(`🛠️ tool ${name}`, { description, parameters }, "yaml"); + trace?.detailsFenced(`🛠️ tool ${name}`, { description, parameters }, "yaml"); }, fileMerge: (n) => { fileMerges.push(n.fn); - trace.itemValue(`file merge`, n.fn); + trace?.itemValue(`file merge`, n.fn); }, outputProcessor: (n) => { outputProcessors.push(n.fn); - trace.itemValue(`output processor`, n.fn.name); + trace?.itemValue(`output processor`, n.fn.name); }, chatParticipant: (n) => { chatParticipants.push(n.participant); - trace.itemValue( + trace?.itemValue( `chat participant`, n.participant.options?.label || n.participant.generator.name, ); }, fileOutput: (n) => { fileOutputs.push(n.output); - trace.itemValue(`file output`, n.output.pattern); + trace?.itemValue(`file output`, n.output.pattern); }, mcpServer: (n) => { mcpServers.push(n.config); - trace.itemValue(`mcp server`, n.config.id); + trace?.itemValue(`mcp server`, n.config.id); }, }); @@ -1516,9 +1517,9 @@ ${fileOutputs.map((fo) => ` ${fo.pattern}: ${fo.description || "generated file responseType = features?.responseType || "json"; dbg(`response type: %s (auto)`, responseType); } - if (responseType) trace.itemValue(`response type`, responseType); + if (responseType) trace?.itemValue(`response type`, responseType); if (responseSchema) { - trace.detailsFenced("📜 response schema", responseSchema); + trace?.detailsFenced("📜 response schema", responseSchema); if (responseType !== "json_schema") { const typeName = "Output"; const schemaTs = JSONSchemaStringifyToTypeScript(responseSchema, { diff --git a/packages/core/src/promptfoo.ts b/packages/core/src/promptfoo.ts index ca81f36129..08f2b16fb7 100644 --- a/packages/core/src/promptfoo.ts +++ b/packages/core/src/promptfoo.ts @@ -80,21 +80,21 @@ function resolveTestProvider( } function renderPurpose(script: PromptScript): string { - const { description, title, id, redteam, jsSource } = script; + const { description, title, redteam, jsSource } = script; const { purpose } = redteam || {}; const trace = new MarkdownTrace(); if (purpose) { - trace.heading(2, "Purpose"); - trace.appendContent(purpose); + trace?.heading(2, "Purpose"); + trace?.appendContent(purpose); } - trace.heading(2, "Prompt details"); - trace.appendContent( + trace?.heading(2, "Prompt details"); + trace?.appendContent( `The prompt is written using GenAIScript (https://microsoft.github.io/genaiscript), a JavaScript-based DSL for creating AI prompts. The generated prompt will be injected in the 'env.files' variable.`, ); - trace.itemValue(`title`, title); - trace.itemValue(`description`, description); - if (jsSource) trace.fence(jsSource, "js"); - return trace.content; + trace?.itemValue(`title`, title); + trace?.itemValue(`description`, description); + if (jsSource) trace?.fence(jsSource, "js"); + return trace?.content; } /** diff --git a/packages/core/src/promptrunner.ts b/packages/core/src/promptrunner.ts index 6468fd0786..9a966a76ff 100644 --- a/packages/core/src/promptrunner.ts +++ b/packages/core/src/promptrunner.ts @@ -1,9 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import debug from "debug"; -const runnerDbg = debug("genaiscript:promptrunner"); - // Import necessary modules and functions for handling chat sessions, templates, file management, etc. import { executeChatSession, tracePromptResult } from "./chat.js"; import { GenerationStatus, Project } from "./server/messages.js"; @@ -11,7 +8,6 @@ import { arrayify } from "./cleaners.js"; import { relativePath } from "./util.js"; import { assert } from "./assert.js"; import { runtimeHost } from "./host.js"; -import { MarkdownTrace } from "./trace.js"; import { CORE_VERSION } from "./version.js"; import { expandFiles } from "./fs.js"; import { dataToMarkdownTable } from "./csv.js"; @@ -32,6 +28,8 @@ import { deleteUndefinedValues } from "./cleaners.js"; import { DEBUG_SCRIPT_CATEGORY } from "./constants.js"; import type { PromptScript } from "./types.js"; import { genaiscriptDebug } from "./debug.js"; +import debug from "debug"; +const runnerDbg = genaiscriptDebug("promptrunner"); const dbg = genaiscriptDebug("env"); // Asynchronously resolve expansion variables needed for a template @@ -46,13 +44,12 @@ const dbg = genaiscriptDebug("env"); */ async function resolveExpansionVars( project: Project, - trace: MarkdownTrace, template: PromptScript, fragment: Fragment, output: OutputTrace, options: GenerationOptions, ): Promise { - const { vars, runDir, runId, applyGitIgnore } = options; + const { vars, runDir, runId, trace, applyGitIgnore } = options; const root = runtimeHost.projectFolder(); assert(!!vars); @@ -159,7 +156,7 @@ export async function runTemplate( } // Resolve expansion variables for the template - const env = await resolveExpansionVars(prj, trace, template, fragment, outputTrace, options); + const env = await resolveExpansionVars(prj, template, fragment, outputTrace, options); const { messages, schemas, @@ -185,6 +182,7 @@ export async function runTemplate( cache, metadata, } = await expandTemplate(prj, template, options, env); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { output, generator, secrets, dbg: envDbg, ...restEnv } = env; runnerDbg(`messages ${messages.length}`); @@ -199,7 +197,7 @@ export async function runTemplate( env: restEnv, label, version, - text: unthink(outputTrace.content), + text: unthink(outputTrace?.content), reasoning: lastAssistantReasoning(messages), edits: [], annotations: [], @@ -229,7 +227,7 @@ export async function runTemplate( env: restEnv, label, version, - text: unthink(outputTrace.content), + text: unthink(outputTrace?.content), reasoning: lastAssistantReasoning(messages), edits: [], annotations: [], @@ -326,7 +324,7 @@ export async function runTemplate( annotations, changelogs, fileEdits, - text: unthink(outputTrace.content), + text: unthink(outputTrace?.content), reasoning: lastAssistantReasoning(messages), version, fences, diff --git a/packages/core/src/runpromptcontext.ts b/packages/core/src/runpromptcontext.ts index f210f1fcfd..26b03c7aa4 100644 --- a/packages/core/src/runpromptcontext.ts +++ b/packages/core/src/runpromptcontext.ts @@ -145,28 +145,28 @@ export function createChatTurnGenerationContext( log: (...args: any[]) => { const line = consoleLogFormat(...args); if (line) { - trace.log(line); + trace?.log(line); stdout.write(line + "\n"); } }, debug: (...args: any[]) => { const line = consoleLogFormat(...args); if (line) { - trace.log(line); + trace?.log(line); logVerbose(line); } }, warn: (...args: any[]) => { const line = consoleLogFormat(...args); if (line) { - trace.warn(line); + trace?.warn(line); logWarn(line); } }, error: (...args: any[]) => { const line = consoleLogFormat(...args); if (line) { - trace.error(line); + trace?.error(line); logError(line); } }, @@ -441,6 +441,7 @@ export function createChatGenerationContext( createToolNode( tool.spec.name, tool.spec.description, + // eslint-disable-next-line @typescript-eslint/no-explicit-any tool.spec.parameters as any, tool.impl, defOptions, @@ -508,6 +509,7 @@ export function createChatGenerationContext( async (args) => { // the LLM automatically adds extract arguments to the context checkCancelled(cancellationToken); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { context, ...argsRest } = args; const { query, ...argsNoQuery } = argsRest; @@ -647,7 +649,7 @@ export function createChatGenerationContext( ): Promise => { checkCancelled(cancellationToken); const { cache, ...rest } = options || {}; - const transcriptionTrace = trace.startTraceDetails("🎤 transcribe"); + const transcriptionTrace = trace?.startTraceDetails("🎤 transcribe"); try { const conn: ModelConnectionOptions = { model: options?.model, @@ -676,9 +678,9 @@ export function createChatGenerationContext( }); const file = await BufferToBlob(await host.readFile(audioFile), "audio/ogg"); const update: () => Promise = async () => { - transcriptionTrace.itemValue(`model`, configuration.model); - transcriptionTrace.itemValue(`file size`, prettyBytes(file.size)); - transcriptionTrace.itemValue(`file type`, file.type); + transcriptionTrace?.itemValue(`model`, configuration.model); + transcriptionTrace?.itemValue(`file size`, prettyBytes(file.size)); + transcriptionTrace?.itemValue(`file type`, file.type); const res = await transcriber( { file, @@ -703,29 +705,29 @@ export function createChatGenerationContext( ); if (cache) { const hit = await _cache.getOrUpdate({ file, ...rest }, update, (res) => !res.error); - transcriptionTrace.itemValue(`cache ${hit.cached ? "hit" : "miss"}`, hit.key); + transcriptionTrace?.itemValue(`cache ${hit.cached ? "hit" : "miss"}`, hit.key); res = hit.value; } else res = await update(); - transcriptionTrace.fence(res.text, "markdown"); - if (res.error) transcriptionTrace.error(errorMessage(res.error)); - if (res.segments) transcriptionTrace.fence(res.segments, "yaml"); + transcriptionTrace?.fence(res.text, "markdown"); + if (res.error) transcriptionTrace?.error(errorMessage(res.error)); + if (res.segments) transcriptionTrace?.fence(res.segments, "yaml"); return res; } catch (e) { logError(e); - transcriptionTrace.error(e); + transcriptionTrace?.error(e); return { text: undefined, error: serializeError(e), } satisfies TranscriptionResult; } finally { - transcriptionTrace.endDetails(); + transcriptionTrace?.endDetails(); } }; const speak = async (input: string, options?: SpeechOptions): Promise => { checkCancelled(cancellationToken); const { cache, voice, instructions, ...rest } = options || {}; - const speechTrace = trace.startTraceDetails("🦜 speak"); + const speechTrace = trace?.startTraceDetails("🦜 speak"); try { const conn: ModelConnectionOptions = { model: options?.model || SPEECH_MODEL_ID, @@ -747,7 +749,7 @@ export function createChatGenerationContext( checkCancelled(cancellationToken); const { speaker } = await resolveLanguageModel(configuration.provider); if (!speaker) throw new Error("speech converter not found for " + info.model); - speechTrace.itemValue(`model`, configuration.model); + speechTrace?.itemValue(`model`, configuration.model); const req = deleteUndefinedValues({ input, model: configuration.model, @@ -759,7 +761,7 @@ export function createChatGenerationContext( cancellationToken, }); if (res.error) { - speechTrace.error(errorMessage(res.error)); + speechTrace?.error(errorMessage(res.error)); return { error: res.error } satisfies SpeechResult; } const h = await hash(res.audio, { length: 20 }); @@ -771,13 +773,13 @@ export function createChatGenerationContext( } satisfies SpeechResult; } catch (e) { logError(e); - speechTrace.error(e); + speechTrace?.error(e); return { filename: undefined, error: serializeError(e), } satisfies SpeechResult; } finally { - speechTrace.endDetails(); + speechTrace?.endDetails(); } }; @@ -793,7 +795,7 @@ export function createChatGenerationContext( checkCancelled(cancellationToken); Object.freeze(runOptions); const { label, applyEdits, throwOnError } = runOptions || {}; - const runTrace = trace.startTraceDetails(`🎁 ${label || "prompt"}`); + const runTrace = trace?.startTraceDetails(`🎁 ${label || "prompt"}`); const messages: ChatCompletionMessageParam[] = []; try { infoCb?.({ text: label || "prompt" }); @@ -879,7 +881,7 @@ export function createChatGenerationContext( if (systemScripts.length) try { - runTrace.startDetails("👾 systems"); + runTrace?.startDetails("👾 systems"); for (const systemId of systemScripts) { checkCancelled(cancellationToken); dbg(`system ${systemId.id}`, { @@ -887,14 +889,13 @@ export function createChatGenerationContext( }); const system = resolveScript(prj, systemId); if (!system) throw new Error(`system template ${systemId.id} not found`); - runTrace.startDetails(`👾 ${system.id}`); + runTrace?.startDetails(`👾 ${system.id}`); if (systemId.parameters) - runTrace.detailsFenced(`parameters`, YAMLStringify(systemId.parameters)); + runTrace?.detailsFenced(`parameters`, YAMLStringify(systemId.parameters)); const sysr = await callExpander( prj, system, mergeEnvVarsWithSystem(env, systemId), - runTrace, genOptions, false, ); @@ -906,21 +907,21 @@ export function createChatGenerationContext( if (sysr.chatParticipants) chatParticipants.push(...sysr.chatParticipants); if (sysr.fileOutputs?.length) fileOutputs.push(...sysr.fileOutputs); if (sysr.disposables?.length) disposables.push(...sysr.disposables); - if (sysr.logs?.length) runTrace.details("📝 console.log", sysr.logs); + if (sysr.logs?.length) runTrace?.details("📝 console.log", sysr.logs); for (const smsg of sysr.messages) { if (smsg.role === "user" && typeof smsg.content === "string") { appendSystemMessage(messages, smsg.content); - runTrace.fence(smsg.content, "markdown"); + runTrace?.fence(smsg.content, "markdown"); } else throw new NotSupportedError("only string user messages supported in system"); } genOptions.logprobs = genOptions.logprobs || system.logprobs; - runTrace.detailsFenced("💻 script source", system.jsSource, "js"); - runTrace.endDetails(); + runTrace?.detailsFenced("💻 script source", system.jsSource, "js"); + runTrace?.endDetails(); if (sysr.status !== "success") throw new Error(`system ${system.id} failed ${sysr.status} ${sysr.statusText}`); } } finally { - runTrace.endDetails(); + runTrace?.endDetails(); } if (genOptions.fallbackTools) { @@ -966,7 +967,7 @@ export function createChatGenerationContext( if (resp.error && throwOnError) throw new Error(errorMessage(resp.error)); return resp; } catch (e) { - runTrace.error(e); + runTrace?.error(e); if (throwOnError) throw e; return { messages, @@ -976,7 +977,7 @@ export function createChatGenerationContext( error: serializeError(e), }; } finally { - runTrace.endDetails(); + runTrace?.endDetails(); } }; @@ -986,7 +987,7 @@ export function createChatGenerationContext( ): Promise<{ image: WorkspaceFile; revisedPrompt?: string }> => { if (!prompt) throw new Error("prompt is missing"); - const imgTrace = trace.startTraceDetails("🖼️ generate image"); + const imgTrace = trace?.startTraceDetails("🖼️ generate image"); try { const { style, quality, size, outputFormat, mime, ...rest } = imageOptions || {}; const conn: ModelConnectionOptions = { @@ -1010,7 +1011,7 @@ export function createChatGenerationContext( checkCancelled(cancellationToken); const { imageGenerator } = await resolveLanguageModel(configuration.provider); if (!imageGenerator) throw new Error("image generator not found for " + info.model); - imgTrace.itemValue(`model`, configuration.model); + imgTrace?.itemValue(`model`, configuration.model); const req = deleteUndefinedValues({ model: configuration.model, prompt: dedent(prompt), @@ -1027,7 +1028,7 @@ export function createChatGenerationContext( }); const duration = m(); if (res.error) { - imgTrace.error(errorMessage(res.error)); + imgTrace?.error(errorMessage(res.error)); return undefined; } dbg(`usage: %o`, res.usage); @@ -1062,8 +1063,8 @@ export function createChatGenerationContext( ); } else logVerbose(`image: ${filename}`); - imgTrace.image(filename, `generated image`); - imgTrace.detailsFenced(`🔀 revised prompt`, res.revisedPrompt); + imgTrace?.image(filename, `generated image`); + imgTrace?.detailsFenced(`🔀 revised prompt`, res.revisedPrompt); return { image: { filename, @@ -1073,7 +1074,7 @@ export function createChatGenerationContext( revisedPrompt: res.revisedPrompt, }; } finally { - imgTrace.endDetails(); + imgTrace?.endDetails(); } }; diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index d1d159cf8a..f2f369258a 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -246,9 +246,10 @@ export function tryValidateJSONWithSchema( * @returns Validation result indicating success status and error details if validation fails. */ export function validateJSONWithSchema( + // eslint-disable-next-line @typescript-eslint/no-explicit-any object: any, schema: JSONSchema, - options?: { trace: MarkdownTrace }, + options?: TraceOptions, ): FileEditValidation { const { trace } = options || {}; if (!schema) diff --git a/packages/core/src/server/client.ts b/packages/core/src/server/client.ts index 92619c03a6..611fb599d6 100644 --- a/packages/core/src/server/client.ts +++ b/packages/core/src/server/client.ts @@ -85,7 +85,7 @@ export class VsCodeClient extends WebSocketClient { if (run) { switch (type) { case "script.progress": { - if (ev.trace) run.trace.appendContent(ev.trace); + if (ev.trace && run.trace) run.trace.appendContent(ev.trace); if (ev.progress && !ev.inner) run.infoCb({ text: ev.progress }); if (ev.response || ev.tokens !== undefined) run.partialCb({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f346a5a1f0..4809351cfa 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2977,12 +2977,6 @@ export interface Parsers { */ unzip(file: WorkspaceFile, options?: ParseZipOptions): Promise; - /** - * Estimates the number of tokens in the content. - * @param content content to tokenize - */ - tokens(content: string | WorkspaceFile): number; - /** * Parses fenced code sections in a markdown text */ @@ -3092,20 +3086,22 @@ export interface Parsers { prompty(file: WorkspaceFile): Promise; } -export interface YAML { +export interface YAMLObject { /** * Parses a YAML string into a JavaScript object using JSON5. */ - (strings: TemplateStringsArray, ...values: any[]): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (strings: TemplateStringsArray, ...values: unknown[]): any; /** * Converts an object to its YAML representation * @param obj */ - stringify(obj: any): string; + stringify(obj: unknown): string; /** * Parses a YAML string to object */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any parse(text: string | WorkspaceFile): any; } @@ -3228,7 +3224,7 @@ export type DiffChangeType = "normal" | "add" | "del"; export type DiffChange = DiffNormalChange | DiffAddChange | DiffDeleteChange; -export interface DIFF { +export interface DIFFObject { /** * Parses a diff string into a structured object * @param input @@ -3264,7 +3260,7 @@ export interface DIFF { ): string; } -export interface XML { +export interface XMLObject { /** * Parses an XML payload to an object * @param text @@ -3312,7 +3308,7 @@ export interface HTMLToMarkdownOptions { disableGfm?: boolean; } -export interface HTML { +export interface HTMLObject { /** * Converts all HTML tables to JSON. * @param html @@ -4087,7 +4083,7 @@ export interface GitHub { client(owner: string, repo: string): GitHub; } -export interface MD { +export interface MDObject { /** * Parses front matter from markdown * @param text @@ -4133,7 +4129,7 @@ export interface MD { export interface GitHubAIDisclaimerOptions extends Record {} -export interface JSONL { +export interface JSONLObject { /** * Parses a JSONL string to an array of objects * @param text @@ -4146,7 +4142,7 @@ export interface JSONL { stringify(objs: any[]): string; } -export interface INI { +export interface INIObject { /** * Parses a .ini file * @param text @@ -4160,7 +4156,7 @@ export interface INI { stringify(value: any): string; } -export interface JSON5 { +export interface JSON5Object { /** * Parses a JSON/YAML/XML string to an object * @param text @@ -4182,7 +4178,7 @@ export interface CSVStringifyOptions { /** * Interface representing CSV operations. */ -export interface CSV { +export interface CSVObject { /** * Parses a CSV string to an array of objects. * @@ -4945,6 +4941,10 @@ export interface ChatGenerationContext extends ChatTurnGenerationContext { ): Promise<{ image: WorkspaceFile; revisedPrompt?: string }>; } +export interface ChatGenerationContextOptions { + ctx?: ChatGenerationContext; +} + export interface GenerationOutput { /** * full chat history @@ -6340,12 +6340,20 @@ export interface PromptContext extends ChatGenerationContext { script(options: PromptArgs): void; system(options: PromptSystemArgs): void; path: Path; - parsers: Parsers; retrieval: Retrieval; - /** - * @deprecated Use `workspace` instead - */ - fs: WorkspaceFileSystem; workspace: WorkspaceFileSystem; host: PromptHost; } + +export type RuntimePromptContext = Pick< + PromptContext, + | "host" + | "env" + | "workspace" + | "retrieval" + | "prompt" + | "runPrompt" + | "generateImage" + | "transcribe" + | "speak" +>; diff --git a/packages/core/src/types/prompt_template.d.ts b/packages/core/src/types/prompt_template.d.ts index 2fc6dc9c15..1fe447eddc 100644 --- a/packages/core/src/types/prompt_template.d.ts +++ b/packages/core/src/types/prompt_template.d.ts @@ -2967,12 +2967,6 @@ interface Parsers { */ unzip(file: WorkspaceFile, options?: ParseZipOptions): Promise; - /** - * Estimates the number of tokens in the content. - * @param content content to tokenize - */ - tokens(content: string | WorkspaceFile): number; - /** * Parses fenced code sections in a markdown text */ @@ -3082,7 +3076,7 @@ interface Parsers { prompty(file: WorkspaceFile): Promise; } -interface YAML { +interface YAMLObject { /** * Parses a YAML string into a JavaScript object using JSON5. */ @@ -3218,7 +3212,7 @@ type DiffChangeType = "normal" | "add" | "del"; type DiffChange = DiffNormalChange | DiffAddChange | DiffDeleteChange; -interface DIFF { +interface DIFFObject { /** * Parses a diff string into a structured object * @param input @@ -3254,7 +3248,7 @@ interface DIFF { ): string; } -interface XML { +interface XMLObject { /** * Parses an XML payload to an object * @param text @@ -3302,7 +3296,7 @@ interface HTMLToMarkdownOptions { disableGfm?: boolean; } -interface HTML { +interface HTMLObject { /** * Converts all HTML tables to JSON. * @param html @@ -4081,7 +4075,7 @@ interface GitHub { client(owner: string, repo: string): GitHub; } -interface MD { +interface MDObject { /** * Parses front matter from markdown * @param text @@ -4125,7 +4119,7 @@ interface MD { ): string; } -interface JSONL { +interface JSONLObject { /** * Parses a JSONL string to an array of objects * @param text @@ -4138,7 +4132,7 @@ interface JSONL { stringify(objs: any[]): string; } -interface INI { +interface INIObject { /** * Parses a .ini file * @param text @@ -4152,7 +4146,7 @@ interface INI { stringify(value: any): string; } -interface JSON5 { +interface JSON5Object { /** * Parses a JSON/YAML/XML string to an object * @param text @@ -4174,7 +4168,7 @@ interface CSVStringifyOptions { /** * Interface representing CSV operations. */ -interface CSV { +interface CSVObject { /** * Parses a CSV string to an array of objects. * @@ -6330,12 +6324,7 @@ interface PromptContext extends ChatGenerationContext { script(options: PromptArgs): void; system(options: PromptSystemArgs): void; path: Path; - parsers: Parsers; retrieval: Retrieval; - /** - * @deprecated Use `workspace` instead - */ - fs: WorkspaceFileSystem; workspace: WorkspaceFileSystem; host: PromptHost; } diff --git a/packages/core/src/types/prompt_type.d.ts b/packages/core/src/types/prompt_type.d.ts index 7bdf86fab4..f710f492cf 100644 --- a/packages/core/src/types/prompt_type.d.ts +++ b/packages/core/src/types/prompt_type.d.ts @@ -154,42 +154,42 @@ declare let workspace: WorkspaceFileSystem; /** * YAML parsing and stringifying functions. */ -declare let YAML: YAML; +declare let YAML: YAMLObject; /** * INI parsing and stringifying. */ -declare let INI: INI; +declare let INI: INIObject; /** * CSV parsing and stringifying. */ -declare let CSV: CSV; +declare let CSV: CSVObject; /** * XML parsing and stringifying. */ -declare let XML: XML; +declare let XML: XMLObject; /** * HTML parsing */ -declare let HTML: HTML; +declare let HTML: HTMLObject; /** * Markdown and frontmatter parsing. */ -declare let MD: MD; +declare let MD: MDObject; /** * JSONL parsing and stringifying. */ -declare let JSONL: JSONL; +declare let JSONL: JSONLObject; /** * JSON5 parsing */ -declare let JSON5: JSON5; +declare let JSON5: JSON5Object; /** * JSON Schema utilities @@ -199,7 +199,7 @@ declare let JSONSchema: JSONSchemaUtilities; /** * Diff utilities */ -declare let DIFF: DIFF; +declare let DIFF: DIFFObject; /** * Access to current LLM chat session information diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts index ce24712e06..3e567477ad 100644 --- a/packages/core/src/usage.ts +++ b/packages/core/src/usage.ts @@ -211,6 +211,8 @@ export class GenerationStats { * @param trace - The MarkdownTrace instance used for tracing. */ trace(trace: MarkdownTrace) { + if (!trace) return; + trace.startDetails("🪙 usage"); try { this.traceStats(trace); diff --git a/packages/core/src/whisperasr.ts b/packages/core/src/whisperasr.ts index 2db3a50472..3dde1387d0 100644 --- a/packages/core/src/whisperasr.ts +++ b/packages/core/src/whisperasr.ts @@ -32,9 +32,9 @@ async function WhisperASRTranscribe( if (req.language) url.searchParams.append(`language`, req.language); dbg(`url: %s`, url.toString()); - trace.itemValue(`url`, `[${url}](${url})`); - trace.itemValue(`size`, req.file.size); - trace.itemValue(`mime`, req.file.type); + trace?.itemValue(`url`, `[${url}](${url})`); + trace?.itemValue(`size`, req.file.size); + trace?.itemValue(`mime`, req.file.type); dbg(`file: %s`, prettyBytes(req.file.size)); @@ -56,7 +56,7 @@ async function WhisperASRTranscribe( // TODO: switch back to cross-fetch in the future const res = await global.fetch(url, freq as any); dbg(`res: %d %s`, res.status, res.statusText); - trace.itemValue(`status`, `${res.status} ${res.statusText}`); + trace?.itemValue(`status`, `${res.status} ${res.statusText}`); const j = await res.json(); if (!res.ok) return { text: undefined, error: j?.error }; else return j; diff --git a/packages/core/src/yaml.ts b/packages/core/src/yaml.ts index 4d52557651..ac9f2e4f05 100644 --- a/packages/core/src/yaml.ts +++ b/packages/core/src/yaml.ts @@ -10,7 +10,7 @@ import { parse, stringify } from "yaml"; import { filenameOrFileToContent } from "./unwrappers.js"; import { dedent } from "./indent.js"; -import type { WorkspaceFile, YAML } from "./types.js"; +import type { WorkspaceFile, YAMLObject } from "./types.js"; /** * Safely attempts to parse a YAML string into a JavaScript object. @@ -28,7 +28,7 @@ import type { WorkspaceFile, YAML } from "./types.js"; * @returns The parsed object, or the defaultValue if parsing fails or * conditions are met. */ -export function YAMLTryParse( +export function YAMLTryParse( text: string | WorkspaceFile, defaultValue?: T, options?: { ignoreLiterals?: boolean }, @@ -40,7 +40,7 @@ export function YAMLTryParse( // Check if parsed result is a primitive and ignoreLiterals is true if (ignoreLiterals && ["number", "boolean", "string"].includes(typeof res)) return defaultValue; return res ?? defaultValue; - } catch (e) { + } catch { // Return defaultValue in case of a parsing error return defaultValue; } @@ -54,6 +54,7 @@ export function YAMLTryParse( * @param text - The YAML string or workspace file to parse. * @returns The parsed JavaScript object. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function YAMLParse(text: string | WorkspaceFile): any { text = filenameOrFileToContent(text); return parse(text); @@ -66,6 +67,7 @@ export function YAMLParse(text: string | WorkspaceFile): any { * @param obj - The object to convert to YAML. * @returns The YAML string representation of the object. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function YAMLStringify(obj: any): string { return stringify(obj, undefined, 2); } @@ -82,7 +84,8 @@ export function YAMLStringify(obj: any): string { * @param values - Corresponding interpolated values to be included in the YAML string. * @returns A parsed object generated from the combined template strings and values. */ -export function createYAML(): YAML { +export function createYAML(): YAMLObject { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = (strings: TemplateStringsArray, ...values: any[]): any => { let result = strings[0]; values.forEach((value, i) => { @@ -93,5 +96,5 @@ export function createYAML(): YAML { }; res.parse = YAMLParse; res.stringify = YAMLStringify; - return Object.freeze(res) satisfies YAML; + return Object.freeze(res) satisfies YAMLObject; } diff --git a/packages/core/test/parsers.test.ts b/packages/core/test/parsers.test.ts index 900ebd1b30..09ce7a974e 100644 --- a/packages/core/test/parsers.test.ts +++ b/packages/core/test/parsers.test.ts @@ -3,7 +3,6 @@ import { describe, test, assert, beforeEach } from "vitest"; import { createParsers } from "../src/parsers.js"; -import { MarkdownTrace } from "../src/trace.js"; import { XLSXParse } from "../src/xlsx.js"; import { readFile } from "fs/promises"; import { resolve } from "path"; @@ -11,14 +10,10 @@ import { TestHost } from "../src/testhost.js"; import { writeFile } from "fs/promises"; describe("parsers", async () => { - let trace: MarkdownTrace; - let model: string; - let parsers: Awaited>; + let parsers: Parsers; beforeEach(async () => { - trace = new MarkdownTrace({}); - model = "test model"; - parsers = await createParsers({ trace, model }); + parsers = createParsers(); TestHost.install(); }); @@ -179,11 +174,6 @@ Back to first level`, assert.strictEqual(result, "I think the answer is 42. "); }); - test("tokens", () => { - const result = parsers.tokens("Hello world"); - assert(typeof result === "number"); - assert(result > 0); - }); test("transcription", () => { const vttContent = `WEBVTT diff --git a/packages/runtime-sample/package.json b/packages/runtime-sample/package.json new file mode 100644 index 0000000000..72669092bd --- /dev/null +++ b/packages/runtime-sample/package.json @@ -0,0 +1,24 @@ +{ + "name": "runtime-sample", + "version": "2.0.6", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "format:check": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --check \"genaisrc/**/*.{ts,cts,mts,mjs}\" \"*.{js,cjs,mjs,json,mjs}\" ", + "format:fix": "prettier --config ../../.prettierrc.json --ignore-path ../../.prettierignore --write \"genaisrc/**/*.{ts,cts,mts,mjs}\" \"*.{js,cjs,mjs,json,mjs}\" ", + "run:node": "node", + "test": "vitest --run" + }, + "dependencies": { + "@genaiscript/runtime": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "p-all": "^5.0.0", + "vitest": "catalog:", + "zod": "^3.25.67", + "zod-to-json-schema": "^3.24.5", + "zx": "catalog:" + } +} diff --git a/packages/runtime-sample/src/poem-function.js b/packages/runtime-sample/src/poem-function.js new file mode 100644 index 0000000000..28d54287c7 --- /dev/null +++ b/packages/runtime-sample/src/poem-function.js @@ -0,0 +1,6 @@ +import "@genaiscript/runtime"; + +export async function writePoem() { + const res = await prompt`write a poem`.options({ model: "echo" }); + return res.text; +} diff --git a/packages/runtime-sample/src/poem-js.js b/packages/runtime-sample/src/poem-js.js new file mode 100644 index 0000000000..b7158cea48 --- /dev/null +++ b/packages/runtime-sample/src/poem-js.js @@ -0,0 +1,7 @@ +import { config } from "@genaiscript/runtime"; +await config(); + +const d = YAML`foo: bar`; + +const res = await prompt`write a poem`.options({ model: "github:openai/gpt-4o" }); +console.log(res.text); diff --git a/packages/runtime-sample/src/poem-ts.ts b/packages/runtime-sample/src/poem-ts.ts new file mode 100644 index 0000000000..2979f1ae79 --- /dev/null +++ b/packages/runtime-sample/src/poem-ts.ts @@ -0,0 +1,5 @@ +import { config } from "@genaiscript/runtime"; +await config(); + +const res = await prompt`write a poem`.options({ model: "github:openai/gpt-4o" }); +console.log(res.text); diff --git a/packages/runtime-sample/test/runtime-poem.test.ts b/packages/runtime-sample/test/runtime-poem.test.ts new file mode 100644 index 0000000000..3fb04398e0 --- /dev/null +++ b/packages/runtime-sample/test/runtime-poem.test.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, test } from "vitest"; +import { writePoem } from "../src/poem-function"; +import { initialize } from "@genaiscript/runtime"; + +describe(`runtime`, async () => { + await initialize(); + await test(`dynamic import`, async () => { + const res = await prompt`write a poem`.options({ model: "echo" }); + console.log(res.text); + // Add assertions if needed + }); + + await test(`poem function`, async () => { + await writePoem(); + }); +}); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index f988616fe4..dfe9037036 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -14,7 +14,8 @@ ], "dependencies": { "@genaiscript/core": "workspace:*", - "@inquirer/prompts": "^7.5.3", + "@inquirer/prompts": "catalog:", + "debug": "catalog:", "dockerode": "^4.0.7", "es-toolkit": "catalog:", "execa": "catalog:", @@ -48,7 +49,7 @@ "lint:fix": "eslint src --fix --fix-type [problem,suggestion]", "pack": "npm pack 2>&1", "prepack": "npm run build", - "test": "vitest" + "test": "vitest --run" }, "keywords": [], "author": "Microsoft Corporation", diff --git a/packages/runtime/src/cast.ts b/packages/runtime/src/cast.ts new file mode 100644 index 0000000000..ae241d7375 --- /dev/null +++ b/packages/runtime/src/cast.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import type { + ChatGenerationContextOptions, + JSONSchema, + JSONSchemaArray, + PromptGenerator, + PromptGeneratorOptions, + StringLike, +} from "@genaiscript/core"; +import { resolveChatGenerationContext } from "./runtime.js"; + +/** + * Converts unstructured text or data into structured JSON format. + * Inspired by https://github.com/prefecthq/marvin. + * + * @param data - Input text or a prompt generator function to convert. + * @param itemSchema - JSON schema defining the target data structure. If `multiple` is true, this will be treated as an array schema. + * @param options - Configuration options for the conversion process, including context, instructions, label, and additional settings. If `multiple` is true, the schema will be treated as an array schema. + * @returns An object containing the converted data, error information if applicable, and the raw text response. + */ +export async function cast( + data: StringLike | PromptGenerator, + itemSchema: JSONSchema, + options?: PromptGeneratorOptions & + ChatGenerationContextOptions & { + multiple?: boolean; + instructions?: string | PromptGenerator; + }, +): Promise<{ data?: unknown; error?: string; text: string }> { + const ctx = resolveChatGenerationContext(options); + const { multiple, instructions, label = `cast text to schema`, ...rest } = options || {}; + const responseSchema = multiple + ? ({ + type: "array", + items: itemSchema, + } satisfies JSONSchemaArray) + : itemSchema; + const res = await ctx.runPrompt( + async (_) => { + if (typeof data === "function") await data(_); + else _.def("SOURCE", data); + _.defSchema("SCHEMA", responseSchema, { format: "json" }); + _.$`You are an expert data converter specializing in transforming unstructured text source into structured data. + Convert the contents of to JSON using schema . + - Treat images as and convert them to JSON. + - Make sure the returned data matches the schema in .`; + if (typeof instructions === "string") _.$`${instructions}`; + else if (typeof instructions === "function") await instructions(_); + }, + { + responseSchema, + ...rest, + label, + }, + ); + const text = parsers.unfence(res.text, "json"); + return res.json ? { text, data: res.json } : { text, error: res.error?.message }; +} diff --git a/packages/runtime/src/classify.ts b/packages/runtime/src/classify.ts new file mode 100644 index 0000000000..b36b8fff4a --- /dev/null +++ b/packages/runtime/src/classify.ts @@ -0,0 +1,159 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * GenAIScript supporting runtime + * This module provides core functionality for text classification, data transformation, + * PDF processing, and file system operations in the GenAIScript environment. + */ +import type { + ChatGenerationContext, + Logprob, + PromptGenerator, + PromptGeneratorOptions, + RunPromptUsage, + StringLike, +} from "@genaiscript/core"; +import { uniq } from "es-toolkit"; +import { resolveChatGenerationContext } from "./runtime.js"; + +/** + * Options for classifying data using AI models. + * + * @property {boolean} [other] - Inject a 'other' label. + * @property {boolean} [explanations] - Explain answers before returning token. + * @property {ChatGenerationContext} [ctx] - Options runPrompt context. + */ +export type ClassifyOptions = { + /** + * When true, adds an 'other' category to handle cases that don't match defined labels + */ + other?: boolean; + /** + * When true, provides explanatory text before the classification result + */ + explanations?: boolean; + /** + * Context for running the classification prompt + */ + ctx?: ChatGenerationContext; +} & Omit; + +/** + * Classifies input text into predefined categories using AI. + * Inspired by https://github.com/prefecthq/marvin. + * + * @param text - Text content to classify or a prompt generator function. + * @param labels - Object mapping label names to their descriptions. + * @param options - Configuration options for classification, including whether to add an "other" category, provide explanations, and specify context. + * @returns Classification result containing the chosen label, confidence metrics, log probabilities, the full answer text, and usage statistics. + * @throws Error if fewer than two labels are provided (including "other"). + */ +export async function classify>( + text: StringLike | PromptGenerator, + labels: L, + options?: ClassifyOptions, +): Promise<{ + label: keyof typeof labels | "other"; + entropy?: number; + logprob?: number; + probPercent?: number; + answer: string; + logprobs?: Record; + usage?: RunPromptUsage; +}> { + const ctx = resolveChatGenerationContext(options); + const { other, explanations, ...rest } = options || {}; + + const entries = Object.entries({ + ...labels, + ...(other + ? { + other: "This label is used when the text does not fit any of the available labels.", + } + : {}), + }).map(([k, v]) => [k.trim().toLowerCase(), v]); + + if (entries.length < 2) throw Error("classify must have at least two label (including other)"); + + const choices = entries.map(([k]) => k); + const allChoices = uniq(choices); + + const res = await ctx.runPrompt( + async (_) => { + _.$`## Expert Classifier +You are a specialized text classification system. +Your task is to carefully read and classify any input text or image into one +of the predefined labels below. +For each label, you will find a short description. Use these descriptions to guide your decision. +`.role("system"); + _.$`## Labels +You must classify the data as one of the following labels. +${entries.map(([id, descr]) => `- Label '${id}': ${descr}`).join("\n")} + +## Output +${explanations ? "Provide a single short sentence justification for your choice." : ""} +Output the label as a single word on the last line (do not emit "Label"). + +`; + _.fence( + `- Label 'yes': funny +- Label 'no': not funny + +DATA: +Why did the chicken cross the road? Because moo. + +Output: +${explanations ? "It's a classic joke but the ending does not relate to the start of the joke." : ""} +no + +`, + { language: "example" }, + ); + if (typeof text === "function") await text(_); + else _.def("DATA", text); + }, + { + model: "classify", + choices: choices, + label: `classify ${choices.join(", ")}`, + logprobs: true, + topLogprobs: Math.min(3, choices.length), + maxTokens: explanations ? 100 : 1, + system: [ + "system.output_plaintext", + "system.safety_jailbreak", + "system.safety_harmful_content", + "system.safety_protected_material", + ], + ...rest, + }, + ); + + // find the last label + const answer = res.text.toLowerCase(); + const indexes = choices.map((l) => answer.lastIndexOf(l)); + const labeli = indexes.reduce((previ, _label, i) => { + if (indexes[i] > indexes[previ]) return i; + else return previ; + }, 0); + const label = entries[labeli][0]; + const logprobs = res.choices + ? (Object.fromEntries( + res.choices.filter((c) => !isNaN(c?.logprob)).map((c, i) => [allChoices[i], c]), + ) as Record) + : undefined; + const logprob = logprobs?.[label]; + const usage = res.usage; + + return { + label, + entropy: logprob?.entropy, + logprob: logprob?.logprob, + probPercent: logprob?.probPercent, + answer, + logprobs, + usage, + }; +} diff --git a/packages/runtime/src/extras.ts b/packages/runtime/src/extras.ts new file mode 100644 index 0000000000..1585e22ac0 --- /dev/null +++ b/packages/runtime/src/extras.ts @@ -0,0 +1,7 @@ +import { delay, uniq, uniqBy, chunk } from "es-toolkit"; +import { z } from "zod"; + +/** + * Utility functions exported for general use + */ +export { delay, uniq, uniqBy, z, chunk }; diff --git a/packages/runtime/src/filetree.ts b/packages/runtime/src/filetree.ts new file mode 100644 index 0000000000..7c7777978c --- /dev/null +++ b/packages/runtime/src/filetree.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * GenAIScript supporting runtime + * This module provides core functionality for text classification, data transformation, + * PDF processing, and file system operations in the GenAIScript environment. + */ +import type { + Awaitable, + ElementOrArray, + FileStats, + OptionsOrString, + WorkspaceFile, + WorkspaceGrepOptions, +} from "@genaiscript/core"; + +/** + * Creates a tree representation of files in the workspace. + * + * @param glob - Glob pattern to match files. + * @param options - Configuration options for tree generation. + * @param options.query - Optional search query to filter files. + * @param options.size - Whether to include file sizes in the output. + * @param options.ignore - Patterns to exclude from the results. + * @param options.frontmatter - Frontmatter fields to extract from markdown files. Only applies to markdown files. + * @param options.preview - Custom function to generate file previews based on file and stats. + * @returns A formatted string representing the file tree structure, including metadata and file sizes if specified. + */ +export async function fileTree( + glob: string, + options?: WorkspaceGrepOptions & { + query?: string | RegExp; + size?: boolean; + ignore?: ElementOrArray; + frontmatter?: OptionsOrString<"title" | "description" | "keywords" | "tags">[]; + preview?: (file: WorkspaceFile, stats: FileStats) => Awaitable; + }, +): Promise { + const { frontmatter, preview, query, size, ignore, ...rest } = options || {}; + const readText = !!(frontmatter || preview); + // TODO + const files = query + ? (await workspace.grep(query, glob, { ...rest, readText })).files + : await workspace.findFiles(glob, { + ignore, + readText, + }); + const tree = await buildTree(files); + return renderTree(tree); + + type TreeNode = { + filename: string; + children?: TreeNode[]; + stats: FileStats; + metadata: string; + }; + async function buildTree(files: WorkspaceFile[]): Promise { + const root: TreeNode[] = []; + + for (const file of files) { + const { filename } = file; + const parts = filename.split(/[/\\]/); + let currentLevel = root; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]; + let node = currentLevel.find((n) => n.filename === part); + if (!node) { + const stats = await workspace.stat(filename); + const metadata: unknown[] = []; + if (frontmatter && /\.mdx?$/i.test(filename)) { + const fm = parsers.frontmatter(file) || {}; + if (fm) + metadata.push( + ...frontmatter + .map((field) => [field, fm[field]]) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`), + ); + } + if (preview) metadata.push(await preview(file, stats)); + node = { + filename: part, + metadata: metadata + .filter((f) => f !== undefined) + .map((s) => String(s)) + .map((s) => s.replace(/\n/g, " ")) + .join(", "), + stats, + }; + currentLevel.push(node); + } + if (index < parts.length - 1) { + if (!node.children) { + node.children = []; + } + currentLevel = node.children; + } + } + } + + return root; + } + + function renderTree(nodes: TreeNode[], prefix = ""): string { + return nodes + .map((node, index) => { + const isLast = index === nodes.length - 1; + const newPrefix = prefix + (isLast ? " " : "│ "); + const children = node.children?.length ? renderTree(node.children, newPrefix) : ""; + const meta = [size ? `${Math.ceil(node.stats.size / 1000)}kb ` : undefined, node.metadata] + .filter((s) => !!s) + .join(", "); + return `${prefix}${isLast ? "└ " : "├ "}${node.filename}${meta ? ` - ${meta}` : ""}\n${children}`; + }) + .join(""); + } +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 7b13990f7e..1c1ccadfa3 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,24 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +export * from "./version.js"; export * from "./docker.js"; export * from "./input.js"; export * from "./log.js"; export * from "./nodehost.js"; export * from "./playwright.js"; +export * from "./classify.js"; +export * from "./makeitbetter.js"; +export * from "./cast.js"; +export * from "./filetree.js"; +export * from "./markdownifypdf.js"; export * from "./runtime.js"; -export * from "./version.js"; - -import { installGlobals } from "@genaiscript/core"; -import { NodeHost } from "./nodehost.js"; - -installGlobals(); - -async function main() { - await NodeHost.install(undefined, undefined); -} - -main().catch((err) => { - console.error("Error during NodeHost installation:", err); - process.exit(1); -}); +export * from "./extras.js"; diff --git a/packages/runtime/src/makeitbetter.ts b/packages/runtime/src/makeitbetter.ts new file mode 100644 index 0000000000..d1c7af9f3f --- /dev/null +++ b/packages/runtime/src/makeitbetter.ts @@ -0,0 +1,30 @@ +import type { ChatGenerationContext, ChatGenerationContextOptions } from "@genaiscript/core"; +import { resolveChatGenerationContext } from "./runtime.js"; + +/** + * Enhances content generation by applying iterative improvements. + * + * @param options - Configuration for the improvement process. + * @param options.ctx - Chat generation context to use. Defaults to the environment generator if not provided. + * @param options.repeat - Number of improvement iterations to perform. Defaults to 1. + * @param options.instructions - Custom instructions for improvement. Defaults to "Make it better!". + * The instructions are applied in each iteration. + */ +export function makeItBetter( + options?: ChatGenerationContextOptions & { + repeat?: number; + instructions?: string; + }, +) { + const ctx = resolveChatGenerationContext(options); + const { repeat = 1, instructions = "Make it better!" } = options || {}; + + let round = 0; + ctx.defChatParticipant((cctx) => { + if (round++ < repeat) { + cctx.console.log(`make it better (round ${round})`); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + cctx.$`${instructions}`; + } + }); +} diff --git a/packages/runtime/src/markdownifypdf.ts b/packages/runtime/src/markdownifypdf.ts new file mode 100644 index 0000000000..19e6b86a2e --- /dev/null +++ b/packages/runtime/src/markdownifypdf.ts @@ -0,0 +1,96 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * GenAIScript supporting runtime + * This module provides core functionality for text classification, data transformation, + * PDF processing, and file system operations in the GenAIScript environment. + */ +import type { + ChatGenerationContext, + ParsePDFOptions, + PromptGenerator, + PromptGeneratorOptions, + WorkspaceFile, +} from "@genaiscript/core"; +import { resolveChatGenerationContext } from "./runtime.js"; + +/** + * Converts a PDF file to markdown format with intelligent formatting preservation. + * + * @param file - PDF file to convert. + * @param options - Configuration options for PDF processing and markdown conversion, including instructions, context, and additional settings. The options can include rendering images, providing custom instructions, and specifying the context for processing. The text and images from the PDF are analyzed to ensure accurate markdown formatting. + * @returns An object containing the original pages, rendered images, and markdown content for each page. + */ +export async function markdownifyPdf( + file: WorkspaceFile, + options?: PromptGeneratorOptions & + ChatGenerationContext & + Omit & { + instructions?: string | PromptGenerator; + ctx?: ChatGenerationContext; + }, +) { + const generator: ChatGenerationContext = resolveChatGenerationContext(options); + const { + label = `markdownify PDF`, + model = "ocr", + responseType = "markdown", + instructions, + ...rest + } = options || {}; + + // extract text and render pages as images + const { pages, images = [] } = await parsers.PDF(file, { + ...rest, + renderAsImage: true, + }); + const markdowns: string[] = []; + for (let i = 0; i < pages.length; ++i) { + const page = pages[i]; + const image = images[i]; + // mix of text and vision + const res = await generator.runPrompt( + async (_) => { + const previousPages = markdowns.slice(-2).join("\n\n"); + if (previousPages.length) _.def("PREVIOUS_PAGES", previousPages); + if (page) _.def("PAGE", page); + if (image) _.defImages(image, { autoCrop: true, greyscale: true }); + _.$`You are an expert at converting PDFs to markdown. + + ## Task + Your task is to analyze the image and extract textual content in markdown format. + + The image is a screenshot of the current page in the PDF document. + We used pdfjs-dist to extract the text of the current page in , use it to help with the conversion. + The text from the previous pages is in , use it to ensure consistency in the conversion. + + ## Instructions + - Ensure markdown text formatting for the extracted text is applied properly by analyzing the image. + - Do not change any content in the original extracted text while applying markdown formatting and do not repeat the extracted text. + - Preserve markdown text formatting if present such as horizontal lines, header levels, footers, bullet points, links/urls, or other markdown elements. + - Extract source code snippets in code fences. + - Do not omit any textual content from the markdown formatted extracted text. + - Do not generate page breaks + - Do not repeat the content. + - Do not include any additional explanations or comments in the markdown formatted extracted text. + `; + if (image) _.$`- For images, generate a short alt-text description.`; + if (typeof instructions === "string") _.$`${instructions}`; + else if (typeof instructions === "function") await instructions(_); + }, + { + ...rest, + model, + label: `${label}: page ${i + 1}`, + responseType, + system: ["system", "system.assistant"], + }, + ); + if (res.error) throw new Error(res.error?.message); + markdowns.push(res.text); + } + + return { pages, images, markdowns }; +} diff --git a/packages/runtime/src/nodehost.ts b/packages/runtime/src/nodehost.ts index b218e5da0f..c6df9c00ce 100644 --- a/packages/runtime/src/nodehost.ts +++ b/packages/runtime/src/nodehost.ts @@ -273,8 +273,8 @@ export class NodeHost extends EventTarget implements RuntimeHost { return this._config; } - static async install(dotEnvPaths: string[], hostConfig: HostConfiguration) { - const h = new NodeHost(dotEnvPaths); + static async install(dotEnvPaths?: ElementOrArray, hostConfig?: HostConfiguration) { + const h = new NodeHost(dotEnvPaths ? arrayify(dotEnvPaths) : undefined); setRuntimeHost(h); if (hostConfig) h.updateHostConfig(hostConfig); await h.readConfig(); diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts index f1b1ac274b..c44774c25d 100644 --- a/packages/runtime/src/runtime.ts +++ b/packages/runtime/src/runtime.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-expressions */ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. @@ -8,427 +7,195 @@ * PDF processing, and file system operations in the GenAIScript environment. */ import type { - Awaitable, + Ffmpeg, + Git, + GitHub, + JSONSchemaUtilities, + Tokenizers, + Parsers, + YAMLObject, + CSVObject, + DIFFObject, + HTMLObject, + INIObject, + JSON5Object, + JSONLObject, + XMLObject, + MDObject, + ModelConnectionOptions, +} from "@genaiscript/core"; +import type { ChatGenerationContext, + ChatGenerationContextOptions, ElementOrArray, - FileStats, - JSONSchema, - JSONSchemaArray, - Logprob, - OptionsOrString, - ParsePDFOptions, - PromptContext, + ExpansionVariables, + HostConfiguration, + ImageGenerationOptions, + Path, PromptGenerator, PromptGeneratorOptions, - RunPromptUsage, - StringLike, + PromptHost, + Retrieval, + RunPromptResult, + RunPromptResultPromiseWithOptions, + RuntimePromptContext, + SpeechOptions, + SpeechResult, + TranscriptionOptions, + TranscriptionResult, WorkspaceFile, - WorkspaceGrepOptions, + WorkspaceFileSystem, } from "@genaiscript/core"; -import { delay, uniq, uniqBy, chunk } from "es-toolkit"; -import { z } from "zod"; - -const globalPromptContext: PromptContext = globalThis as unknown as PromptContext; +import { + buildProject, + createPromptContext, + DEBUG_SCRIPT_CATEGORY, + generateId, + getRunDir, + installGlobalPromptContext, + genaiscriptDebug, + LARGE_MODEL_ID, + MarkdownTrace, + installGlobals, + GenerationStats, + setQuiet, +} from "@genaiscript/core"; +import { NodeHost } from "./nodehost.js"; +import debug from "debug"; +const dbg = genaiscriptDebug("runtime"); + +declare global { + const parsers: Parsers; + const YAML: YAMLObject; + const INI: INIObject; + const CSV: CSVObject; + const XML: XMLObject; + const HTML: HTMLObject; + const MD: MDObject; + const JSONL: JSONLObject; + const JSON5: JSON5Object; + const JSONSchema: JSONSchemaUtilities; + const DIFF: DIFFObject; + const github: GitHub; + const git: Git; + const ffmpeg: Ffmpeg; + const tokenizers: Tokenizers; +} -/** - * Utility functions exported for general use - */ -export { delay, uniq, uniqBy, z, chunk }; -/** - * Options for classifying data using AI models. - * - * @property {boolean} [other] - Inject a 'other' label. - * @property {boolean} [explanations] - Explain answers before returning token. - * @property {ChatGenerationContext} [ctx] - Options runPrompt context. - */ -export type ClassifyOptions = { +declare global { + const host: PromptHost; + const retrieval: Retrieval; + const workspace: WorkspaceFileSystem; + const env: ExpansionVariables; + const path: Path; /** - * When true, adds an 'other' category to handle cases that don't match defined labels + * Expands and executes prompt + * @param generator */ - other?: boolean; + function runPrompt( + generator: string | PromptGenerator, + options?: PromptGeneratorOptions, + ): Promise; + /** - * When true, provides explanatory text before the classification result + * Expands and executes the prompt */ - explanations?: boolean; + function prompt( + strings: TemplateStringsArray, + ...args: unknown[] + ): RunPromptResultPromiseWithOptions; + /** - * Context for running the classification prompt + * Transcribes audio to text. + * @param audio An audio file to transcribe. + * @param options */ - ctx?: ChatGenerationContext; -} & Omit; - -/** - * Classifies input text into predefined categories using AI. - * Inspired by https://github.com/prefecthq/marvin. - * - * @param text - Text content to classify or a prompt generator function. - * @param labels - Object mapping label names to their descriptions. - * @param options - Configuration options for classification, including whether to add an "other" category, provide explanations, and specify context. - * @returns Classification result containing the chosen label, confidence metrics, log probabilities, the full answer text, and usage statistics. - * @throws Error if fewer than two labels are provided (including "other"). - */ -export async function classify>( - text: StringLike | PromptGenerator, - labels: L, - options?: ClassifyOptions, -): Promise<{ - label: keyof typeof labels | "other"; - entropy?: number; - logprob?: number; - probPercent?: number; - answer: string; - logprobs?: Record; - usage?: RunPromptUsage; -}> { - const { other, explanations, ...rest } = options || {}; - - const entries = Object.entries({ - ...labels, - ...(other - ? { - other: "This label is used when the text does not fit any of the available labels.", - } - : {}), - }).map(([k, v]) => [k.trim().toLowerCase(), v]); - - if (entries.length < 2) throw Error("classify must have at least two label (including other)"); - - const choices = entries.map(([k]) => k); - const allChoices = uniq(choices); - const ctx = options?.ctx || globalPromptContext.env.generator; - - const res = await ctx.runPrompt( - async (_) => { - _.$`## Expert Classifier -You are a specialized text classification system. -Your task is to carefully read and classify any input text or image into one -of the predefined labels below. -For each label, you will find a short description. Use these descriptions to guide your decision. -`.role("system"); - _.$`## Labels -You must classify the data as one of the following labels. -${entries.map(([id, descr]) => `- Label '${id}': ${descr}`).join("\n")} - -## Output -${explanations ? "Provide a single short sentence justification for your choice." : ""} -Output the label as a single word on the last line (do not emit "Label"). - -`; - _.fence( - `- Label 'yes': funny -- Label 'no': not funny - -DATA: -Why did the chicken cross the road? Because moo. - -Output: -${explanations ? "It's a classic joke but the ending does not relate to the start of the joke." : ""} -no - -`, - { language: "example" }, - ); - if (typeof text === "function") await text(_); - else _.def("DATA", text); - }, - { - model: "classify", - choices: choices, - label: `classify ${choices.join(", ")}`, - logprobs: true, - topLogprobs: Math.min(3, choices.length), - maxTokens: explanations ? 100 : 1, - system: [ - "system.output_plaintext", - "system.safety_jailbreak", - "system.safety_harmful_content", - "system.safety_protected_material", - ], - ...rest, - }, - ); + function transcribe( + audio: string | WorkspaceFile, + options?: TranscriptionOptions, + ): Promise; - // find the last label - const answer = res.text.toLowerCase(); - const indexes = choices.map((l) => answer.lastIndexOf(l)); - const labeli = indexes.reduce((previ, _label, i) => { - if (indexes[i] > indexes[previ]) return i; - else return previ; - }, 0); - const label = entries[labeli][0]; - const logprobs = res.choices - ? (Object.fromEntries( - res.choices.filter((c) => !isNaN(c?.logprob)).map((c, i) => [allChoices[i], c]), - ) as Record) - : undefined; - const logprob = logprobs?.[label]; - const usage = res.usage; + /** + * Converts text to speech. + * @param text + * @param options + */ + function speak(text: string, options?: SpeechOptions): Promise; - return { - label, - entropy: logprob?.entropy, - logprob: logprob?.logprob, - probPercent: logprob?.probPercent, - answer, - logprobs, - usage, - }; + /** + * Generate an image and return the workspace file. + * @param prompt + * @param options + */ + function generateImage( + prompt: string, + options?: ImageGenerationOptions, + ): Promise<{ image: WorkspaceFile; revisedPrompt?: string }>; } -/** - * Enhances content generation by applying iterative improvements. - * - * @param options - Configuration for the improvement process. - * @param options.ctx - Chat generation context to use. Defaults to the environment generator if not provided. - * @param options.repeat - Number of improvement iterations to perform. Defaults to 1. - * @param options.instructions - Custom instructions for improvement. Defaults to "Make it better!". - * The instructions are applied in each iteration. - */ -export function makeItBetter(options?: { - ctx?: ChatGenerationContext; - repeat?: number; - instructions?: string; -}) { - const { repeat = 1, instructions = "Make it better!" } = options || {}; - const ctx = options?.ctx || globalPromptContext.env.generator; - - let round = 0; - ctx.defChatParticipant((cctx) => { - if (round++ < repeat) { - cctx.console.log(`make it better (round ${round})`); - cctx.$`${instructions}`; - } - }); +let _nodeHost: NodeHost | undefined; + +export function resolveChatGenerationContext( + options?: ChatGenerationContextOptions, +): ChatGenerationContext { + const { ctx } = options || {}; + if (ctx) return ctx; + const globalPromptContext: RuntimePromptContext = globalThis as unknown as RuntimePromptContext; + const generator = globalPromptContext.env?.generator; + if (!generator) + throw new Error("You must pass a chat generation context when using the runtime."); + return generator; } /** - * Converts unstructured text or data into structured JSON format. - * Inspired by https://github.com/prefecthq/marvin. - * - * @param data - Input text or a prompt generator function to convert. - * @param itemSchema - JSON schema defining the target data structure. If `multiple` is true, this will be treated as an array schema. - * @param options - Configuration options for the conversion process, including context, instructions, label, and additional settings. If `multiple` is true, the schema will be treated as an array schema. - * @returns An object containing the converted data, error information if applicable, and the raw text response. + * Configure the default GenAIScript runtime environment. + * Installs the global helpers and configure host and env. */ -export async function cast( - data: StringLike | PromptGenerator, - itemSchema: JSONSchema, - options?: PromptGeneratorOptions & { - multiple?: boolean; - instructions?: string | PromptGenerator; - ctx?: ChatGenerationContext; - }, -): Promise<{ data?: unknown; error?: string; text: string }> { - const { - ctx = globalPromptContext.env.generator, - multiple, - instructions, - label = `cast text to schema`, - ...rest - } = options || {}; - const responseSchema = multiple - ? ({ - type: "array", - items: itemSchema, - } satisfies JSONSchemaArray) - : itemSchema; - const res = await ctx.runPrompt( - async (_) => { - if (typeof data === "function") await data(_); - else _.def("SOURCE", data); - _.defSchema("SCHEMA", responseSchema, { format: "json" }); - _.$`You are an expert data converter specializing in transforming unstructured text source into structured data. - Convert the contents of to JSON using schema . - - Treat images as and convert them to JSON. - - Make sure the returned data matches the schema in .`; - if (typeof instructions === "string") _.$`${instructions}`; - else if (typeof instructions === "function") await instructions(_); +export async function initialize( + options?: { + dotEnvPaths?: ElementOrArray; + hostConfig?: HostConfiguration; + } & ModelConnectionOptions, +): Promise { + if (_nodeHost) throw new Error("Runtime already configured. Call `config` only once."); + + setQuiet(true); + const { dotEnvPaths, hostConfig, ...rest } = options || {}; + dbg(`config %o`, dotEnvPaths); + dbg(`hostConfig %O`, hostConfig); + installGlobals(); + await NodeHost.install(dotEnvPaths, hostConfig); + const prj = await buildProject(); + const runId = generateId(); + const runDir = getRunDir("runtime", runId); + const output = new MarkdownTrace(); + const env: ExpansionVariables = { + runId, + runDir, + dir: process.cwd(), + files: [], + vars: {}, + secrets: {}, + meta: { + id: "", + ...rest, }, + generator: undefined, + output, + dbg: debug(DEBUG_SCRIPT_CATEGORY), + }; + const model = LARGE_MODEL_ID; + const ctx = await createPromptContext( + prj, + env, { - responseSchema, ...rest, - label, + inner: true, + stats: new GenerationStats(model), + model: LARGE_MODEL_ID, + userState: {}, }, + LARGE_MODEL_ID, ); - const text = globalPromptContext.parsers.unfence(res.text, "json"); - return res.json ? { text, data: res.json } : { text, error: res.error?.message }; -} - -/** - * Converts a PDF file to markdown format with intelligent formatting preservation. - * - * @param file - PDF file to convert. - * @param options - Configuration options for PDF processing and markdown conversion, including instructions, context, and additional settings. The options can include rendering images, providing custom instructions, and specifying the context for processing. The text and images from the PDF are analyzed to ensure accurate markdown formatting. - * @returns An object containing the original pages, rendered images, and markdown content for each page. - */ -export async function markdownifyPdf( - file: WorkspaceFile, - options?: PromptGeneratorOptions & - Omit & { - instructions?: string | PromptGenerator; - ctx?: ChatGenerationContext; - }, -) { - const { - ctx = globalPromptContext.env.generator, - label = `markdownify PDF`, - model = "ocr", - responseType = "markdown", - instructions, - ...rest - } = options || {}; - - // extract text and render pages as images - const { pages, images = [] } = await globalPromptContext.parsers.PDF(file, { - ...rest, - renderAsImage: true, - }); - const markdowns: string[] = []; - for (let i = 0; i < pages.length; ++i) { - const page = pages[i]; - const image = images[i]; - // mix of text and vision - const res = await ctx.runPrompt( - async (_) => { - const previousPages = markdowns.slice(-2).join("\n\n"); - if (previousPages.length) _.def("PREVIOUS_PAGES", previousPages); - if (page) _.def("PAGE", page); - if (image) _.defImages(image, { autoCrop: true, greyscale: true }); - _.$`You are an expert at converting PDFs to markdown. - - ## Task - Your task is to analyze the image and extract textual content in markdown format. - - The image is a screenshot of the current page in the PDF document. - We used pdfjs-dist to extract the text of the current page in , use it to help with the conversion. - The text from the previous pages is in , use it to ensure consistency in the conversion. - - ## Instructions - - Ensure markdown text formatting for the extracted text is applied properly by analyzing the image. - - Do not change any content in the original extracted text while applying markdown formatting and do not repeat the extracted text. - - Preserve markdown text formatting if present such as horizontal lines, header levels, footers, bullet points, links/urls, or other markdown elements. - - Extract source code snippets in code fences. - - Do not omit any textual content from the markdown formatted extracted text. - - Do not generate page breaks - - Do not repeat the content. - - Do not include any additional explanations or comments in the markdown formatted extracted text. - `; - if (image) globalPromptContext.$`- For images, generate a short alt-text description.`; - if (typeof instructions === "string") _.$`${instructions}`; - else if (typeof instructions === "function") await instructions(_); - }, - { - ...rest, - model, - label: `${label}: page ${i + 1}`, - responseType, - system: ["system", "system.assistant"], - }, - ); - if (res.error) throw new Error(res.error?.message); - markdowns.push(res.text); - } - - return { pages, images, markdowns }; -} - -/** - * Creates a tree representation of files in the workspace. - * - * @param glob - Glob pattern to match files. - * @param options - Configuration options for tree generation. - * @param options.query - Optional search query to filter files. - * @param options.size - Whether to include file sizes in the output. - * @param options.ignore - Patterns to exclude from the results. - * @param options.frontmatter - Frontmatter fields to extract from markdown files. Only applies to markdown files. - * @param options.preview - Custom function to generate file previews based on file and stats. - * @returns A formatted string representing the file tree structure, including metadata and file sizes if specified. - */ -export async function fileTree( - glob: string, - options?: WorkspaceGrepOptions & { - query?: string | RegExp; - size?: boolean; - ignore?: ElementOrArray; - frontmatter?: OptionsOrString<"title" | "description" | "keywords" | "tags">[]; - preview?: (file: WorkspaceFile, stats: FileStats) => Awaitable; - }, -): Promise { - const { frontmatter, preview, query, size, ignore, ...rest } = options || {}; - const readText = !!(frontmatter || preview); - // TODO - const files = query - ? (await globalPromptContext.workspace.grep(query, glob, { ...rest, readText })).files - : await globalPromptContext.workspace.findFiles(glob, { - ignore, - readText, - }); - const tree = await buildTree(files); - return renderTree(tree); - - type TreeNode = { - filename: string; - children?: TreeNode[]; - stats: FileStats; - metadata: string; - }; - async function buildTree(files: WorkspaceFile[]): Promise { - const root: TreeNode[] = []; - - for (const file of files) { - const { filename } = file; - const parts = filename.split(/[/\\]/); - let currentLevel = root; - for (let index = 0; index < parts.length; index++) { - const part = parts[index]; - let node = currentLevel.find((n) => n.filename === part); - if (!node) { - const stats = await globalPromptContext.workspace.stat(filename); - const metadata: unknown[] = []; - if (frontmatter && /\.mdx?$/i.test(filename)) { - const fm = globalPromptContext.parsers.frontmatter(file) || {}; - if (fm) - metadata.push( - ...frontmatter - .map((field) => [field, fm[field]]) - .filter(([, v]) => v !== undefined) - .map(([k, v]) => `${k}: ${JSON.stringify(v)}`), - ); - } - if (preview) metadata.push(await preview(file, stats)); - node = { - filename: part, - metadata: metadata - .filter((f) => f !== undefined) - .map((s) => String(s)) - .map((s) => s.replace(/\n/g, " ")) - .join(", "), - stats, - }; - currentLevel.push(node); - } - if (index < parts.length - 1) { - if (!node.children) { - node.children = []; - } - currentLevel = node.children; - } - } - } - - return root; - } - - function renderTree(nodes: TreeNode[], prefix = ""): string { - return nodes - .map((node, index) => { - const isLast = index === nodes.length - 1; - const newPrefix = prefix + (isLast ? " " : "│ "); - const children = node.children?.length ? renderTree(node.children, newPrefix) : ""; - const meta = [size ? `${Math.ceil(node.stats.size / 1000)}kb ` : undefined, node.metadata] - .filter((s) => !!s) - .join(", "); - return `${prefix}${isLast ? "└ " : "├ "}${node.filename}${meta ? ` - ${meta}` : ""}\n${children}`; - }) - .join(""); - } + installGlobalPromptContext(ctx); } diff --git a/packages/sample/genaisrc/ai-kitchen.genai.mts b/packages/sample/genaisrc/ai-kitchen.genai.mts index 155b8e8f44..148d4a2a8c 100644 --- a/packages/sample/genaisrc/ai-kitchen.genai.mts +++ b/packages/sample/genaisrc/ai-kitchen.genai.mts @@ -4,7 +4,7 @@ import { delay, uniq } from "@genaiscript/runtime"; * In order to run this script, you will need the following: * * - ffmpeg installed on your system - * - a valid (Azure) OpenAI API key with whister enabled -- or a local whisper server running + * - a valid (Azure) OpenAI API key with whisper enabled -- or a local whisper server running * - the usual LLM configuration * * Invoke the cli with the following command: diff --git a/packages/sample/genaisrc/git.genai.mts b/packages/sample/genaisrc/git.genai.mts index c8f541a051..fe058c487c 100644 --- a/packages/sample/genaisrc/git.genai.mts +++ b/packages/sample/genaisrc/git.genai.mts @@ -31,7 +31,7 @@ console.log({ log }); for (const commit of log.slice(0, 10)) { const diff = await git.diff({ base: commit.sha, llmify: true }); - console.log({ commit: commit.sha, diff: parsers.tokens(diff) + " tokens" }); + console.log({ commit: commit.sha, diff: (await tokenizers.count(diff)) + " tokens" }); } const client = git.client("."); diff --git a/packages/sample/genaisrc/readability.genai.mjs b/packages/sample/genaisrc/readability.genai._mjs similarity index 100% rename from packages/sample/genaisrc/readability.genai.mjs rename to packages/sample/genaisrc/readability.genai._mjs diff --git a/packages/sample/genaisrc/samples/cmt.genai.mts b/packages/sample/genaisrc/samples/cmt.genai.mts index 97f0c4fdf9..8d51a1d597 100644 --- a/packages/sample/genaisrc/samples/cmt.genai.mts +++ b/packages/sample/genaisrc/samples/cmt.genai.mts @@ -77,7 +77,7 @@ async function processFile(file: WorkspaceFile) { // Function to add comments to code async function addComments(file: WorkspaceFile): Promise { let { filename, content } = file; - if (parsers.tokens(file) > 20000) return undefined; // too big + if ((await tokenizers.count(file.content)) > 20000) return undefined; // too big const res = await runPrompt( (ctx) => { diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 7575c49e8d..069cddc5f2 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,9 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -/// -/// -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import "@vscode-elements/elements/dist/vscode-button"; import "@vscode-elements/elements/dist/vscode-single-select"; @@ -185,7 +183,6 @@ function RefreshButton() { function ScriptSelect() { const scripts = useScripts(); const { scriptid, setScriptid } = useScriptId(); - const { refresh } = useApi(); const { filename } = useScript() || {}; return ( @@ -207,7 +204,7 @@ function ScriptSelect() { {scripts .filter((s) => !s.isSystem && !s.unlisted) .map(({ id, title }) => ( - + {id} ))} @@ -282,6 +279,7 @@ function PromptParametersFields() { .map(([key, fieldSchema]) => { return ( []; headers: string[] }) { const { rows, headers } = props; const x = headers[0]; const ys = headers.slice(1); diff --git a/packages/web/src/Buttons.tsx b/packages/web/src/Buttons.tsx index ca038b88be..62e148110c 100644 --- a/packages/web/src/Buttons.tsx +++ b/packages/web/src/Buttons.tsx @@ -7,6 +7,7 @@ import "@vscode-elements/elements/dist/vscode-button"; import AIDisclaimer from "./AIDisclaimer"; import { hosted } from "./configuration"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any function extractTextFromChildren(children: any): string { if (!children) return ""; @@ -14,12 +15,14 @@ function extractTextFromChildren(children: any): string { if (typeof child === "string") { return text + child; } else if (React.isValidElement(child)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any return text + extractTextFromChildren((child.props as any).children); } return text; }, "") as string; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any function CopyButton(props: { children: any; text?: string }) { const { children, text } = props; const [copied, setCopied] = useState(false); @@ -30,7 +33,7 @@ function CopyButton(props: { children: any; text?: string }) { await navigator.clipboard.writeText(res); setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch (err) {} + } catch {} }; const title = copied ? "Copied!" : "Copy"; const buttonText = copied ? "Copied!" : ""; @@ -41,6 +44,7 @@ function CopyButton(props: { children: any; text?: string }) { ); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any function SaveButton(props: { filename?: string; children: any; text?: string }) { const { children, text, filename } = props; const [saved, setSaved] = useState(false); @@ -64,7 +68,7 @@ function SaveButton(props: { filename?: string; children: any; text?: string }) } setSaved(true); setTimeout(() => setSaved(false), 2000); - } catch (err) {} + } catch {} }; const title = saved ? "Saved!" : "Save"; return ( @@ -79,6 +83,7 @@ function SaveButton(props: { filename?: string; children: any; text?: string }) } export default function CopySaveButtons(props: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any children: any; filename?: string; text?: string; diff --git a/packages/web/src/DataTable.tsx b/packages/web/src/DataTable.tsx index de6472ec79..e0c7cbaa1d 100644 --- a/packages/web/src/DataTable.tsx +++ b/packages/web/src/DataTable.tsx @@ -9,7 +9,7 @@ import "@vscode-elements/elements/dist/vscode-table-row"; import "@vscode-elements/elements/dist/vscode-table-header-cell"; import "@vscode-elements/elements/dist/vscode-table-cell"; -export default function DataTable(props: { rows: any[]; headers: string[] }) { +export default function DataTable(props: { rows: Record[]; headers: string[] }) { const { rows, headers } = props; if (!rows?.length || !headers?.length) return null; diff --git a/packages/web/src/DataTableTabs.tsx b/packages/web/src/DataTableTabs.tsx index ff298b0c3b..d64763816b 100644 --- a/packages/web/src/DataTableTabs.tsx +++ b/packages/web/src/DataTableTabs.tsx @@ -14,7 +14,7 @@ import DataTable from "./DataTable"; export default function DataTableTabs(props: { children: string; chart?: string }) { const { children, chart } = props; - const rows: any[] = JSON5TryParse(children); + const rows: Record[] = JSON5TryParse(children); // find rows that are numbers if (!rows?.length && typeof rows[0] !== "object") return null; diff --git a/packages/web/src/JSONSchema.tsx b/packages/web/src/JSONSchema.tsx index ad33cbbd69..46325b434f 100644 --- a/packages/web/src/JSONSchema.tsx +++ b/packages/web/src/JSONSchema.tsx @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -/// -/// import React, { Dispatch, SetStateAction, startTransition, useEffect, useState } from "react"; import { underscore } from "inflection"; @@ -233,7 +231,7 @@ function JSONSchemaSimpleTypeFormField(props: { function fieldDisplayName(fieldPrefix: string, fieldName: string, field: JSONSchemaSimpleType) { return underscore( (fieldPrefix ? `${fieldPrefix} / ` : fieldPrefix) + (field.title || fieldName), - ).replaceAll(/[_\.]/g, " "); + ).replaceAll(/[_.]/g, " "); } export function JSONBooleanOptionsGroup(props: { diff --git a/packages/web/src/Results.tsx b/packages/web/src/Results.tsx index e169c2c87c..1bac7ca0e6 100644 --- a/packages/web/src/Results.tsx +++ b/packages/web/src/Results.tsx @@ -204,6 +204,7 @@ function StatsTabPanel() { const result = useResult(); const { usage } = result || {}; if (!usage) return null; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { cost, ...rest } = usage || {}; const md = usage diff --git a/packages/web/src/RunnerContext.tsx b/packages/web/src/RunnerContext.tsx index f8727514fc..6dd71e1c63 100644 --- a/packages/web/src/RunnerContext.tsx +++ b/packages/web/src/RunnerContext.tsx @@ -54,7 +54,7 @@ export function RunnerProvider({ children }: { children: React.ReactNode }) { useEventListener(client, RunClient.SCRIPT_START_EVENT, start, false); const runUpdate = useCallback( - (e: Event) => + () => startTransition(() => { setRunId(client.runId); setState("running"); @@ -64,7 +64,7 @@ export function RunnerProvider({ children }: { children: React.ReactNode }) { useEventListener(client, RunClient.RUN_EVENT, runUpdate, false); const end = useCallback( - (e: Event) => + () => startTransition(() => { setState(undefined); if (runId === client.runId) setResult(client.result); @@ -74,7 +74,7 @@ export function RunnerProvider({ children }: { children: React.ReactNode }) { useEventListener(client, RunClient.SCRIPT_END_EVENT, end, false); const appendTrace = useCallback( - (evt: Event) => + () => startTransition(() => { setTrace(() => client.trace); setOutput(() => client.output); diff --git a/packages/web/src/useUrlSearchParam.ts b/packages/web/src/useUrlSearchParam.ts index 2c995dceed..d627cf3a40 100644 --- a/packages/web/src/useUrlSearchParam.ts +++ b/packages/web/src/useUrlSearchParam.ts @@ -11,6 +11,7 @@ export function useUrlSearchParams( const [state, setState] = useState(initialValues); useEffect(() => { const params = new URLSearchParams(window.location.search); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const newState: any = {}; Object.entries(fields).forEach(([key, field]) => { const { type } = field; @@ -43,7 +44,7 @@ export function useUrlSearchParams( if (type === "string") { if (value !== "") params.set(key, value as string); } else if (type === "boolean") { - if (!!value) params.set(key, "1"); + if (value) params.set(key, "1"); } else if (type === "integer" || type === "number") { const v = value as number; if (!isNaN(v)) params.set(key, v.toString()); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 037eefb7fe..fb06f2dbe3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@eslint/js': specifier: ^9.29.0 version: 9.29.0 + '@inquirer/prompts': + specifier: ^7.5.3 + version: 7.5.3 '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -45,6 +48,9 @@ catalogs: tslib: specifier: ^2.8.1 version: 2.8.1 + tsx: + specifier: ^4.19.4 + version: 4.19.4 typescript: specifier: 5.8.3 version: 5.8.3 @@ -63,7 +69,7 @@ importers: .: devDependencies: '@inquirer/prompts': - specifier: ^7.5.3 + specifier: 'catalog:' version: 7.5.3(@types/node@24.0.3) '@intellectronica/ruler': specifier: ^0.2.3 @@ -89,6 +95,9 @@ importers: tailwindcss: specifier: ^4.1.10 version: 4.1.10 + tsx: + specifier: 'catalog:' + version: 4.19.4 turbo: specifier: ^2.5.4 version: 2.5.4 @@ -202,7 +211,7 @@ importers: specifier: workspace:* version: link:../runtime '@inquirer/prompts': - specifier: ^7.5.3 + specifier: 'catalog:' version: 7.5.3(@types/node@22.15.28) '@modelcontextprotocol/sdk': specifier: ^1.13.1 @@ -515,7 +524,7 @@ importers: specifier: 'catalog:' version: 2.8.1 tsx: - specifier: ^4.19.4 + specifier: 'catalog:' version: 4.19.4 turndown: specifier: ^7.2.0 @@ -633,8 +642,11 @@ importers: specifier: workspace:* version: link:../core '@inquirer/prompts': - specifier: ^7.5.3 + specifier: 'catalog:' version: 7.5.3(@types/node@22.15.28) + debug: + specifier: 'catalog:' + version: 4.4.1 dockerode: specifier: ^4.0.7 version: 4.0.7 @@ -700,6 +712,31 @@ importers: specifier: 'catalog:' version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.28)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(tsx@4.19.4)(yaml@2.8.0) + packages/runtime-sample: + dependencies: + '@genaiscript/runtime': + specifier: workspace:* + version: link:../runtime + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.15.28 + p-all: + specifier: ^5.0.0 + version: 5.0.0 + vitest: + specifier: 'catalog:' + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.15.28)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(tsx@4.19.4)(yaml@2.8.0) + zod: + specifier: ^3.25.67 + version: 3.25.67 + zod-to-json-schema: + specifier: ^3.24.5 + version: 3.24.5(zod@3.25.67) + zx: + specifier: 'catalog:' + version: 8.6.0 + packages/sample: dependencies: '@genaiscript/runtime': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cd5ff8ff14..f727cddbd6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,10 +3,11 @@ packages: - packages/* catalog: - '@eslint/js': ^9.29.0 - '@types/debug': ^4.1.12 - '@types/node': 22.15.28 - '@vitest/coverage-istanbul': ^3.2.4 + "@eslint/js": ^9.29.0 + "@inquirer/prompts": ^7.5.3 + "@types/debug": ^4.1.12 + "@types/node": 22.15.28 + "@vitest/coverage-istanbul": ^3.2.4 debug: ^4.4.1 eslint: ^9.29.0 es-toolkit: ^1.39.3 @@ -16,17 +17,17 @@ catalog: rimraf: ^6.0.1 tshy: ^3.0.2 tslib: ^2.8.1 + tsx: ^4.19.4 typescript: 5.8.3 typescript-eslint: ^8.34.1 vitest: 3.2.4 zx: ^8.6.0 onlyBuiltDependencies: - - '@ast-grep/lang-c' - - '@ast-grep/lang-cpp' - - '@ast-grep/lang-csharp' - - '@ast-grep/lang-python' - - '@lvce-editor/ripgrep' - - 'sharp' - - '@vscode/vsce-sign' - \ No newline at end of file + - "@ast-grep/lang-c" + - "@ast-grep/lang-cpp" + - "@ast-grep/lang-csharp" + - "@ast-grep/lang-python" + - "@lvce-editor/ripgrep" + - "sharp" + - "@vscode/vsce-sign"