From 5ba4af218e6f9a3941ba87344bce97aa9a2f6f9f Mon Sep 17 00:00:00 2001 From: Gerard Date: Tue, 23 Sep 2025 19:06:02 +0200 Subject: [PATCH] Add special {{@now}} parameter that returns current date in ISO format - Modified resolveExpression in compile.ts to handle '@now' identifier - Added test case for {{@now}} functionality - {{@now}} is replaced at runtime with new Date().toISOString() --- package.json | 3 +- src/compiler/compile.test.ts | 29 +++++++++++++++++++ src/compiler/compile.ts | 4 +++ src/compiler/logic/index.ts | 2 +- .../logic/nodes/assignmentExpression.ts | 18 ++++++++++-- src/compiler/logic/nodes/identifier.ts | 13 ++++++++- src/compiler/logic/types.ts | 2 ++ src/compiler/scan.test.ts | 27 +++++++++++++++++ src/compiler/scan.ts | 4 +++ src/constants.ts | 5 ++++ src/error/errors.ts | 4 +++ 11 files changed, 106 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 7ca9bac..b8996cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "promptl-ai", - "version": "0.7.5", + "version": "0.7.6", "author": "Latitude Data", "license": "MIT", "description": "Compiler for PromptL, the prompt language", @@ -24,6 +24,7 @@ "dist" ], "scripts": { + "prepare": "npm run build", "dev": "rollup -c -w", "build": "rollup -c", "build:lib": "rollup -c rollup.config.mjs", diff --git a/src/compiler/compile.test.ts b/src/compiler/compile.test.ts index 6c14b81..93d7c35 100644 --- a/src/compiler/compile.test.ts +++ b/src/compiler/compile.test.ts @@ -181,6 +181,35 @@ describe('variable assignment', async () => { expect(result).toBe('') }) + it('special $now parameter returns current date in ISO format', async () => { + const prompt = ` + {{ $now }} + ` + const result = await getCompiledText(prompt) + // Check that it's a valid ISO date string (JSON stringified) + expect(result).toMatch(/^"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z"$/) + }) + + it('special $now can be used in expressions', async () => { + const prompt = ` + {{ time = $now }} + {{ time }} + ` + const result = await getCompiledText(prompt) + // Check that it's a valid ISO date string (JSON stringified) + expect(result).toMatch(/^"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z"$/) + }) + + it('special $now can be used in method calls', async () => { + const prompt = ` + {{ $now.getTime() }} + ` + const result = await getCompiledText(prompt) + const timestamp = parseInt(result.trim()) + expect(timestamp).toBeGreaterThan(0) + expect(timestamp).toBeLessThan(Date.now() + 1000) // within 1 second + }) + it('parameters are available as variables in the prompt', async () => { const prompt = ` {{ foo }} diff --git a/src/compiler/compile.ts b/src/compiler/compile.ts index 5cbe12b..9f1d613 100644 --- a/src/compiler/compile.ts +++ b/src/compiler/compile.ts @@ -34,6 +34,7 @@ import type { ResolveBaseNodeProps, } from './types' import { getCommonIndent, removeCommonIndent } from './utils' +import { SPECIAL_RESOLVERS } from '../constants' export type CompilationStatus = { completed: boolean @@ -60,6 +61,7 @@ export class Compile { private globalScope: Scope private defaultRole: MessageRole private includeSourceMap: boolean + private builtins: Record any> private messages: Message[] = [] private globalConfig: Config | undefined @@ -97,6 +99,7 @@ export class Compile { this.ast = ast this.stepResponse = stepResponse this.defaultRole = defaultRole + this.builtins = SPECIAL_RESOLVERS this.referenceFn = referenceFn this.fullPath = fullPath this.includeSourceMap = includeSourceMap @@ -294,6 +297,7 @@ export class Compile { return await resolveLogicNode({ node: expression, scope, + builtins: this.builtins, raiseError: this.expressionError.bind(this), }) } diff --git a/src/compiler/logic/index.ts b/src/compiler/logic/index.ts index 40f1136..13a21ca 100644 --- a/src/compiler/logic/index.ts +++ b/src/compiler/logic/index.ts @@ -27,7 +27,7 @@ export async function updateScopeContextForNode( props: UpdateScopeContextProps, ) { const type = props.node.type as NodeType - if (!nodeResolvers[type]) { + if (!updateScopeContextResolvers[type]) { throw new Error(`Unknown node type: ${type}`) } diff --git a/src/compiler/logic/nodes/assignmentExpression.ts b/src/compiler/logic/nodes/assignmentExpression.ts index ba8550d..d7de2bc 100644 --- a/src/compiler/logic/nodes/assignmentExpression.ts +++ b/src/compiler/logic/nodes/assignmentExpression.ts @@ -136,6 +136,7 @@ async function assignToProperty({ export function updateScopeContext({ node, scopeContext, + builtins, raiseError, }: UpdateScopeContextProps) { const assignmentOperator = node.operator @@ -143,11 +144,19 @@ export function updateScopeContext({ raiseError(errors.unsupportedOperator(assignmentOperator), node) } - updateScopeContextForNode({ node: node.right, scopeContext, raiseError }) + updateScopeContextForNode({ + node: node.right, + scopeContext, + builtins, + raiseError, + }) if (node.left.type === 'Identifier') { // Variable assignment const assignedVariableName = (node.left as Identifier).name + if (assignedVariableName in builtins) { + raiseError(errors.assignmentToBuiltin(assignedVariableName), node) + } if (assignmentOperator != '=') { // Update an existing variable if (!scopeContext.definedVariables.has(assignedVariableName)) { @@ -159,7 +168,12 @@ export function updateScopeContext({ } if (node.left.type === 'MemberExpression') { - updateScopeContextForNode({ node: node.left, scopeContext, raiseError }) + updateScopeContextForNode({ + node: node.left, + scopeContext, + builtins, + raiseError, + }) return } diff --git a/src/compiler/logic/nodes/identifier.ts b/src/compiler/logic/nodes/identifier.ts index bd712a2..6d80db9 100644 --- a/src/compiler/logic/nodes/identifier.ts +++ b/src/compiler/logic/nodes/identifier.ts @@ -9,7 +9,14 @@ import type { Identifier } from 'estree' * ### Identifier * Represents a variable from the scope. */ -export async function resolve({ node, scope }: ResolveNodeProps) { +export async function resolve({ + node, + scope, + builtins, +}: ResolveNodeProps) { + if (node.name in builtins) { + return builtins[node.name]!() + } if (!scope.exists(node.name)) { return undefined } @@ -19,8 +26,12 @@ export async function resolve({ node, scope }: ResolveNodeProps) { export function updateScopeContext({ node, scopeContext, + builtins, raiseError, }: UpdateScopeContextProps) { + if (node.name in builtins) { + return + } if (!scopeContext.definedVariables.has(node.name)) { if (scopeContext.onlyPredefinedVariables === undefined) { scopeContext.usedUndefinedVariables.add(node.name) diff --git a/src/compiler/logic/types.ts b/src/compiler/logic/types.ts index 7b9eb99..355fb8b 100644 --- a/src/compiler/logic/types.ts +++ b/src/compiler/logic/types.ts @@ -26,11 +26,13 @@ type RaiseErrorFn = ( export type ResolveNodeProps = { node: N scope: Scope + builtins: Record any> raiseError: RaiseErrorFn } export type UpdateScopeContextProps = { node: N scopeContext: ScopeContext + builtins: Record any> raiseError: RaiseErrorFn } diff --git a/src/compiler/scan.test.ts b/src/compiler/scan.test.ts index 2ea59fc..5b03ffb 100644 --- a/src/compiler/scan.test.ts +++ b/src/compiler/scan.test.ts @@ -588,6 +588,33 @@ describe('parameters', async () => { expect(metadata.parameters).toEqual(new Set(['foo', 'bar', 'arr'])) }) + + it('does not include special identifiers as parameters', async () => { + const prompt = ` + {{ $now }} + ` + + const metadata = await scan({ + prompt: removeCommonIndent(prompt), + }) + + expect(metadata.parameters).toEqual(new Set()) + }) + + it('raises error when assigning to builtin', async () => { + const prompt = ` + {{ $now = "2023-01-01" }} + ` + + const metadata = await scan({ + prompt: removeCommonIndent(prompt), + }) + + expect(metadata.errors).toHaveLength(1) + expect(metadata.errors[0]!.message).toBe( + "Cannot assign to builtin variable: '$now'", + ) + }) }) describe('referenced prompts', async () => { diff --git a/src/compiler/scan.ts b/src/compiler/scan.ts index 262d48d..bb94a5e 100644 --- a/src/compiler/scan.ts +++ b/src/compiler/scan.ts @@ -4,6 +4,7 @@ import { CUSTOM_MESSAGE_ROLE_ATTR, REFERENCE_DEPTH_LIMIT, REFERENCE_PATH_ATTR, + SPECIAL_RESOLVERS, TAG_NAMES, } from '$promptl/constants' import CompileError, { error } from '$promptl/error/error' @@ -57,6 +58,7 @@ export class Scan { private withParameters?: string[] private requireConfig: boolean private configSchema?: z.ZodType + private builtins: Record any> private config?: Config private configPosition?: { start: number; end: number } @@ -95,6 +97,7 @@ export class Scan { this.withParameters = withParameters this.configSchema = configSchema this.requireConfig = requireConfig ?? false + this.builtins = SPECIAL_RESOLVERS this.resolvedPrompt = document.content this.includedPromptPaths = new Set([this.fullPath]) @@ -194,6 +197,7 @@ export class Scan { await updateScopeContextForNode({ node, scopeContext, + builtins: this.builtins, raiseError: this.expressionError.bind(this), }) } diff --git a/src/constants.ts b/src/constants.ts index c984ec0..bf5dd66 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -40,3 +40,8 @@ export enum KEYWORDS { export const RESERVED_KEYWORDS = Object.values(KEYWORDS) export const RESERVED_TAGS = Object.values(TAG_NAMES) +export const SPECIAL_IDENTIFIERS = new Set(['$now']) + +export const SPECIAL_RESOLVERS: Record unknown> = { + $now: () => new Date(), +} diff --git a/src/error/errors.ts b/src/error/errors.ts index 6b016f3..a7ec50d 100644 --- a/src/error/errors.ts +++ b/src/error/errors.ts @@ -137,6 +137,10 @@ export default { code: 'invalid-assignment', message: 'Invalid assignment', }, + assignmentToBuiltin: (name: string) => ({ + code: 'assignment-to-builtin', + message: `Cannot assign to builtin variable: '${name}'`, + }), unknownTag: (name: string) => ({ code: 'unknown-tag', message: `Unknown tag: '${name}'`,