@f5xc-salesdemos/pi-utils 14.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/prompt.ts ADDED
@@ -0,0 +1,401 @@
1
+ import type { HelperDelegate, HelperOptions, Template, TemplateDelegate } from "handlebars";
2
+ import Handlebars from "handlebars";
3
+
4
+ export type { HelperDelegate, HelperOptions, Template, TemplateDelegate };
5
+
6
+ export type PromptRenderPhase = "pre-render" | "post-render";
7
+
8
+ export interface PromptFormatOptions {
9
+ renderPhase?: PromptRenderPhase;
10
+ replaceAsciiSymbols?: boolean;
11
+ boldRfc2119Keywords?: boolean;
12
+ }
13
+
14
+ // Opening XML tag (not self-closing, not closing)
15
+ const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
16
+ // Closing XML tag
17
+ const CLOSING_XML = /^<\/([a-z_-]+)>$/;
18
+ // Handlebars block start: {{#if}}, {{#has}}, {{#list}}, etc.
19
+ const OPENING_HBS = /^\{\{#/;
20
+ // Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc.
21
+ const CLOSING_HBS = /^\{\{\//;
22
+ // List item (- or * or 1.)
23
+ const LIST_ITEM = /^(?:[-*]\s|\d+\.\s)/;
24
+ // Table row
25
+ const TABLE_ROW = /^\|.*\|$/;
26
+ // Table separator (|---|---|)
27
+ const TABLE_SEP = /^\|[-:\s|]+\|$/;
28
+
29
+ /** RFC 2119 keywords used in prompts. */
30
+ const RFC2119_KEYWORDS = /\b(?:MUST NOT|SHOULD NOT|SHALL NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|SHALL|MUST|MAY)\b/g;
31
+
32
+ function boldRfc2119Keywords(line: string): string {
33
+ return line.replace(RFC2119_KEYWORDS, (match, offset, source) => {
34
+ const isAlreadyBold =
35
+ source[offset - 2] === "*" &&
36
+ source[offset - 1] === "*" &&
37
+ source[offset + match.length] === "*" &&
38
+ source[offset + match.length + 1] === "*";
39
+ if (isAlreadyBold) {
40
+ return match;
41
+ }
42
+ return `**${match}**`;
43
+ });
44
+ }
45
+
46
+ /** Compact a table row by trimming cell padding */
47
+ function compactTableRow(line: string): string {
48
+ const cells = line.split("|");
49
+ return cells.map(c => c.trim()).join("|");
50
+ }
51
+
52
+ /** Compact a table separator row */
53
+ function compactTableSep(line: string): string {
54
+ const cells = line.split("|").filter(c => c.trim());
55
+ const normalized = cells.map(c => {
56
+ const trimmed = c.trim();
57
+ const left = trimmed.startsWith(":");
58
+ const right = trimmed.endsWith(":");
59
+ if (left && right) return ":---:";
60
+ if (left) return ":---";
61
+ if (right) return "---:";
62
+ return "---";
63
+ });
64
+ return `|${normalized.join("|")}|`;
65
+ }
66
+
67
+ function replaceCommonAsciiSymbols(line: string): string {
68
+ return line
69
+ .replace(/\.{3}/g, "…")
70
+ .replace(/<->/g, "↔")
71
+ .replace(/->/g, "→")
72
+ .replace(/<-/g, "←")
73
+ .replace(/!=/g, "≠")
74
+ .replace(/<=/g, "≤")
75
+ .replace(/>=/g, "≥");
76
+ }
77
+
78
+ export function format(content: string, options: PromptFormatOptions = {}): string {
79
+ const {
80
+ renderPhase = "post-render",
81
+ replaceAsciiSymbols = false,
82
+ boldRfc2119Keywords: shouldBoldRfc2119 = false,
83
+ } = options;
84
+ const isPreRender = renderPhase === "pre-render";
85
+ const lines = content.split("\n");
86
+ const result: string[] = [];
87
+ let inCodeBlock = false;
88
+ const topLevelTags: string[] = [];
89
+
90
+ for (let i = 0; i < lines.length; i++) {
91
+ let line = lines[i].trimEnd();
92
+ let trimmedStart = line.trimStart();
93
+ if (trimmedStart.startsWith("```") || trimmedStart.startsWith("~~~")) {
94
+ inCodeBlock = !inCodeBlock;
95
+ result.push(line);
96
+ continue;
97
+ }
98
+
99
+ if (inCodeBlock) {
100
+ result.push(line);
101
+ continue;
102
+ }
103
+
104
+ if (replaceAsciiSymbols) {
105
+ line = replaceCommonAsciiSymbols(line);
106
+ }
107
+ trimmedStart = line.trimStart();
108
+ const trimmed = line.trim();
109
+
110
+ const isOpeningXml = OPENING_XML.test(trimmedStart) && !trimmedStart.endsWith("/>");
111
+ if (isOpeningXml && line.length === trimmedStart.length) {
112
+ const match = OPENING_XML.exec(trimmedStart);
113
+ if (match) topLevelTags.push(match[1]);
114
+ }
115
+
116
+ const closingMatch = CLOSING_XML.exec(trimmedStart);
117
+ if (closingMatch) {
118
+ const tagName = closingMatch[1];
119
+ if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) {
120
+ topLevelTags.pop();
121
+ }
122
+ } else if (isPreRender && trimmedStart.startsWith("{{")) {
123
+ /* keep indentation as-is in pre-render for Handlebars markers */
124
+ } else if (TABLE_SEP.test(trimmedStart)) {
125
+ const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
126
+ line = `${leadingWhitespace}${compactTableSep(trimmedStart)}`;
127
+ } else if (TABLE_ROW.test(trimmedStart)) {
128
+ const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
129
+ line = `${leadingWhitespace}${compactTableRow(trimmedStart)}`;
130
+ }
131
+
132
+ if (shouldBoldRfc2119) {
133
+ line = boldRfc2119Keywords(line);
134
+ }
135
+
136
+ const isBlank = trimmed === "";
137
+ if (isBlank) {
138
+ const prevLine = result[result.length - 1]?.trim() ?? "";
139
+ const nextLine = lines[i + 1]?.trim() ?? "";
140
+
141
+ if (LIST_ITEM.test(nextLine)) {
142
+ continue;
143
+ }
144
+
145
+ if (OPENING_XML.test(prevLine) || (isPreRender && OPENING_HBS.test(prevLine))) {
146
+ continue;
147
+ }
148
+
149
+ if (CLOSING_XML.test(nextLine) || (isPreRender && CLOSING_HBS.test(nextLine))) {
150
+ continue;
151
+ }
152
+
153
+ const prevIsBlank = prevLine === "";
154
+ if (prevIsBlank) {
155
+ continue;
156
+ }
157
+ }
158
+
159
+ if (CLOSING_XML.test(trimmed) || (isPreRender && CLOSING_HBS.test(trimmed))) {
160
+ while (result.length > 0 && result[result.length - 1].trim() === "") {
161
+ result.pop();
162
+ }
163
+ }
164
+
165
+ result.push(line);
166
+ }
167
+
168
+ while (result.length > 0 && result[result.length - 1].trim() === "") {
169
+ result.pop();
170
+ }
171
+
172
+ return result.join("\n");
173
+ }
174
+
175
+ export interface TemplateContext extends Record<string, unknown> {
176
+ args?: string[];
177
+ ARGUMENTS?: string;
178
+ arguments?: string;
179
+ }
180
+
181
+ const handlebars = Handlebars.create();
182
+
183
+ handlebars.registerHelper("arg", function (this: TemplateContext, index: number | string): string {
184
+ const args = this.args ?? [];
185
+ const parsedIndex = typeof index === "number" ? index : Number.parseInt(index, 10);
186
+ if (!Number.isFinite(parsedIndex)) return "";
187
+ const zeroBased = parsedIndex - 1;
188
+ if (zeroBased < 0) return "";
189
+ return args[zeroBased] ?? "";
190
+ });
191
+
192
+ /**
193
+ * {{#list items prefix="- " suffix="" join="\n"}}{{this}}{{/list}}
194
+ * Renders an array with customizable prefix, suffix, and join separator.
195
+ * Note: Use \n in join for newlines (will be unescaped automatically).
196
+ */
197
+ handlebars.registerHelper(
198
+ "list",
199
+ function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string {
200
+ if (!Array.isArray(context) || context.length === 0) return "";
201
+ const prefix = (options.hash.prefix as string) ?? "";
202
+ const suffix = (options.hash.suffix as string) ?? "";
203
+ const rawSeparator = (options.hash.join as string) ?? "\n";
204
+ const separator = rawSeparator.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
205
+ return context.map(item => `${prefix}${options.fn(item)}${suffix}`).join(separator);
206
+ },
207
+ );
208
+
209
+ /**
210
+ * {{join array ", "}}
211
+ * Joins an array with a separator (default: ", ").
212
+ */
213
+ handlebars.registerHelper("join", (context: unknown[], separator?: unknown): string => {
214
+ if (!Array.isArray(context)) return "";
215
+ const sep = typeof separator === "string" ? separator : ", ";
216
+ return context.join(sep);
217
+ });
218
+
219
+ /**
220
+ * {{default value "fallback"}}
221
+ * Returns the value if truthy, otherwise returns the fallback.
222
+ */
223
+ handlebars.registerHelper("default", (value: unknown, defaultValue: unknown): unknown => value || defaultValue);
224
+
225
+ /**
226
+ * {{pluralize count "item" "items"}}
227
+ * Returns "1 item" or "5 items" based on count.
228
+ */
229
+ handlebars.registerHelper(
230
+ "pluralize",
231
+ (count: number, singular: string, plural: string): string => `${count} ${count === 1 ? singular : plural}`,
232
+ );
233
+
234
+ /**
235
+ * {{#when value "==" compare}}...{{else}}...{{/when}}
236
+ * Conditional block with comparison operators: ==, ===, !=, !==, >, <, >=, <=
237
+ */
238
+ handlebars.registerHelper(
239
+ "when",
240
+ function (this: unknown, lhs: unknown, operator: string, rhs: unknown, options: Handlebars.HelperOptions): string {
241
+ const ops: Record<string, (a: unknown, b: unknown) => boolean> = {
242
+ "==": (a, b) => a === b,
243
+ "===": (a, b) => a === b,
244
+ "!=": (a, b) => a !== b,
245
+ "!==": (a, b) => a !== b,
246
+ ">": (a, b) => (a as number) > (b as number),
247
+ "<": (a, b) => (a as number) < (b as number),
248
+ ">=": (a, b) => (a as number) >= (b as number),
249
+ "<=": (a, b) => (a as number) <= (b as number),
250
+ };
251
+ const fn = ops[operator];
252
+ if (!fn) return options.inverse(this);
253
+ return fn(lhs, rhs) ? options.fn(this) : options.inverse(this);
254
+ },
255
+ );
256
+
257
+ /**
258
+ * {{#ifAny a b c}}...{{else}}...{{/ifAny}}
259
+ * True if any argument is truthy.
260
+ */
261
+ handlebars.registerHelper("ifAny", function (this: unknown, ...args: unknown[]): string {
262
+ const options = args.pop() as Handlebars.HelperOptions;
263
+ return args.some(Boolean) ? options.fn(this) : options.inverse(this);
264
+ });
265
+
266
+ /**
267
+ * {{#ifAll a b c}}...{{else}}...{{/ifAll}}
268
+ * True if all arguments are truthy.
269
+ */
270
+ handlebars.registerHelper("ifAll", function (this: unknown, ...args: unknown[]): string {
271
+ const options = args.pop() as Handlebars.HelperOptions;
272
+ return args.every(Boolean) ? options.fn(this) : options.inverse(this);
273
+ });
274
+
275
+ /**
276
+ * {{#table rows headers="Col1|Col2"}}{{col1}}|{{col2}}{{/table}}
277
+ * Generates a markdown table from an array of objects.
278
+ */
279
+ handlebars.registerHelper(
280
+ "table",
281
+ function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string {
282
+ if (!Array.isArray(context) || context.length === 0) return "";
283
+ const headersStr = options.hash.headers as string | undefined;
284
+ const headers = headersStr?.split("|") ?? [];
285
+ const separator = headers.map(() => "---").join(" | ");
286
+ const headerRow = headers.length > 0 ? `| ${headers.join(" | ")} |\n| ${separator} |\n` : "";
287
+ const rows = context.map(item => `| ${options.fn(item).trim()} |`).join("\n");
288
+ return headerRow + rows;
289
+ },
290
+ );
291
+
292
+ /**
293
+ * {{#codeblock lang="diff"}}...{{/codeblock}}
294
+ * Wraps content in a fenced code block.
295
+ */
296
+ handlebars.registerHelper("codeblock", function (this: unknown, options: Handlebars.HelperOptions): string {
297
+ const lang = (options.hash.lang as string) ?? "";
298
+ const content = options.fn(this).trim();
299
+ return `\`\`\`${lang}\n${content}\n\`\`\``;
300
+ });
301
+
302
+ /**
303
+ * {{#xml "tag"}}content{{/xml}}
304
+ * Wraps content in XML-style tags. Returns empty string if content is empty.
305
+ */
306
+ handlebars.registerHelper("xml", function (this: unknown, tag: string, options: Handlebars.HelperOptions): string {
307
+ const content = options.fn(this).trim();
308
+ if (!content) return "";
309
+ return `<${tag}>\n${content}\n</${tag}>`;
310
+ });
311
+
312
+ /**
313
+ * {{escapeXml value}}
314
+ * Escapes XML special characters: & < > "
315
+ */
316
+ handlebars.registerHelper("escapeXml", (value: unknown): string => {
317
+ if (value == null) return "";
318
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
319
+ });
320
+
321
+ /**
322
+ * {{len array}}
323
+ * Returns the length of an array or string.
324
+ */
325
+ handlebars.registerHelper("len", (value: unknown): number => {
326
+ if (Array.isArray(value)) return value.length;
327
+ if (typeof value === "string") return value.length;
328
+ return 0;
329
+ });
330
+
331
+ /**
332
+ * {{add a b}}
333
+ * Adds two numbers.
334
+ */
335
+ handlebars.registerHelper("add", (a: number, b: number): number => (a ?? 0) + (b ?? 0));
336
+
337
+ /**
338
+ * {{sub a b}}
339
+ * Subtracts b from a.
340
+ */
341
+ handlebars.registerHelper("sub", (a: number, b: number): number => (a ?? 0) - (b ?? 0));
342
+
343
+ /**
344
+ * {{#has collection item}}...{{else}}...{{/has}}
345
+ * Checks if an array includes an item or if a Set/Map has a key.
346
+ */
347
+ handlebars.registerHelper(
348
+ "has",
349
+ function (this: unknown, collection: unknown, item: unknown, options: Handlebars.HelperOptions): string {
350
+ let found = false;
351
+ if (Array.isArray(collection)) {
352
+ found = collection.includes(item);
353
+ } else if (collection instanceof Set) {
354
+ found = collection.has(item);
355
+ } else if (collection instanceof Map) {
356
+ found = collection.has(item);
357
+ } else if (collection && typeof collection === "object") {
358
+ if (typeof item === "string" || typeof item === "number" || typeof item === "symbol") {
359
+ found = item in collection;
360
+ }
361
+ }
362
+ return found ? options.fn(this) : options.inverse(this);
363
+ },
364
+ );
365
+
366
+ /**
367
+ * {{includes array item}}
368
+ * Returns true if array includes item. For use in other helpers.
369
+ */
370
+ handlebars.registerHelper("includes", (collection: unknown, item: unknown): boolean => {
371
+ if (Array.isArray(collection)) return collection.includes(item);
372
+ if (collection instanceof Set) return collection.has(item);
373
+ if (collection instanceof Map) return collection.has(item);
374
+ return false;
375
+ });
376
+
377
+ /**
378
+ * {{not value}}
379
+ * Returns logical NOT of value. For use in subexpressions.
380
+ */
381
+ handlebars.registerHelper("not", (value: unknown): boolean => !value);
382
+
383
+ handlebars.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value));
384
+
385
+ export function registerHelper(name: string, fn: HelperDelegate): void {
386
+ handlebars.registerHelper(name, fn);
387
+ }
388
+
389
+ export function registerPartial(name: string, fn: Template): void {
390
+ handlebars.registerPartial(name, fn);
391
+ }
392
+
393
+ export function compile(template: string): (context: TemplateContext) => string {
394
+ return handlebars.compile(template, { noEscape: true, strict: false });
395
+ }
396
+
397
+ export function render(template: string, context: TemplateContext = {}): string {
398
+ const compiled = compile(template);
399
+ const rendered = compiled(context ?? {});
400
+ return format(rendered, { renderPhase: "post-render" });
401
+ }