@boxxy/prompt-template 0.0.0-stage → 0.2.3
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/LICENSE +21 -0
- package/README.md +37 -2
- package/dist/constants.d.ts +3 -0
- package/dist/find-prompt-template-fault.d.ts +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/prompt-engine.d.ts +2 -0
- package/dist/render-prompt-template.d.ts +2 -0
- package/dist/types.d.ts +3 -0
- package/package.json +38 -4
- package/src/constants.ts +87 -0
- package/src/find-prompt-template-fault.ts +151 -0
- package/src/index.ts +4 -0
- package/src/prompt-engine.ts +23 -0
- package/src/render-prompt-template.ts +13 -0
- package/src/types.ts +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the boxxy authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@boxxy/prompt-template</h1>
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@boxxy/prompt-template"><img src="https://img.shields.io/npm/v/@boxxy/prompt-template" alt="npm version"></a>
|
|
6
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p>
|
|
10
|
+
<a href="../../docs/design/boxxy/README.md">Documentation</a>
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
The launch prompt template of a boxxy recipe: its placeholders, its load check and its render.
|
|
15
|
+
|
|
16
|
+
A recipe's prompt is a [Liquid](https://liquidjs.com/) template. The rules loader checks each
|
|
17
|
+
template when it loads the rules, and dispatch renders it when it claims an attempt.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Install the package from [npm](https://www.npmjs.com/package/@boxxy/prompt-template) with Bun:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bun add @boxxy/prompt-template
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- `promptPlaceholders`, the names a template may use.
|
|
32
|
+
- `findPromptTemplateFault`, which returns why a template cannot render, or `null` when it can.
|
|
33
|
+
- `renderPromptTemplate`, which renders a template with a value for each placeholder.
|
|
34
|
+
|
|
35
|
+
## Documentation
|
|
36
|
+
|
|
37
|
+
[The launch prompt](../../docs/design/boxxy/v1.md#the-launch-prompt) lists the placeholders and
|
|
38
|
+
their values.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const promptPlaceholders: readonly ['ticket', 'title', 'outcome', 'body', 'acceptance', 'design', 'findings', 'notes', 'repo', 'attempt_id', 'input'];
|
|
2
|
+
export declare const promptTags: readonly ['if', 'unless', 'case', 'for', 'raw', 'comment', '#'];
|
|
3
|
+
export declare const promptFilters: readonly ['escape', 'xml_escape', 'escape_once', 'newline_to_br', 'strip_html', 'abs', 'at_least', 'at_most', 'ceil', 'divided_by', 'floor', 'minus', 'plus', 'modulo', 'times', 'round', 'slugify', 'join', 'last', 'first', 'reverse', 'sort', 'sort_natural', 'size', 'map', 'sum', 'compact', 'concat', 'push', 'unshift', 'pop', 'shift', 'slice', 'uniq', 'lstrip', 'downcase', 'upcase', 'remove', 'remove_first', 'remove_last', 'rstrip', 'split', 'strip', 'strip_newlines', 'squish', 'capitalize', 'replace', 'replace_first', 'replace_last', 'truncate', 'truncatewords', 'normalize_whitespace', 'number_of_words', 'array_to_sentence_string', 'base64_encode', 'sha256', 'hmac_sha256', 'default', 'raw', 'jsonify', 'to_integer', 'json', 'inspect'];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function findPromptTemplateFault(template: string): string | null;
|
package/dist/index.d.ts
ADDED
package/dist/types.d.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/prompt-template",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "The launch prompt template of a boxxy recipe: its placeholders, its load check and its render",
|
|
5
|
+
"homepage": "https://github.com/zgeoff/boxxy/tree/main/libs/prompt-template#readme",
|
|
6
|
+
"bugs": "https://github.com/zgeoff/boxxy/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/zgeoff/boxxy.git",
|
|
11
|
+
"directory": "libs/prompt-template"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.ts",
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./src/index.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "rm -rf dist && tsrs -p tsconfig.build.json",
|
|
30
|
+
"typecheck": "tsrs --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"liquidjs": "10.30.1",
|
|
34
|
+
"tiny-invariant": "1.3.3"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.3",
|
|
38
|
+
"node": ">=24"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The names a launch prompt template may use. The loader rejects a template that names any other.
|
|
2
|
+
export const promptPlaceholders = [
|
|
3
|
+
'ticket',
|
|
4
|
+
'title',
|
|
5
|
+
'outcome',
|
|
6
|
+
'body',
|
|
7
|
+
'acceptance',
|
|
8
|
+
'design',
|
|
9
|
+
'findings',
|
|
10
|
+
'notes',
|
|
11
|
+
'repo',
|
|
12
|
+
'attempt_id',
|
|
13
|
+
'input',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
// The tags a template may use. A tag that binds a name, such as `assign`, would let a branch that
|
|
17
|
+
// did not run leave a name unbound, and the render would throw at claim.
|
|
18
|
+
export const promptTags = ['if', 'unless', 'case', 'for', 'raw', 'comment', '#'] as const;
|
|
19
|
+
|
|
20
|
+
// The filters a template may use: each renders any text with any arguments. Liquid's other
|
|
21
|
+
// filters read the clock or a random number, read a field by name, check their argument count at
|
|
22
|
+
// render, or throw on some text, such as `url_decode` on `100%`.
|
|
23
|
+
export const promptFilters = [
|
|
24
|
+
'escape',
|
|
25
|
+
'xml_escape',
|
|
26
|
+
'escape_once',
|
|
27
|
+
'newline_to_br',
|
|
28
|
+
'strip_html',
|
|
29
|
+
'abs',
|
|
30
|
+
'at_least',
|
|
31
|
+
'at_most',
|
|
32
|
+
'ceil',
|
|
33
|
+
'divided_by',
|
|
34
|
+
'floor',
|
|
35
|
+
'minus',
|
|
36
|
+
'plus',
|
|
37
|
+
'modulo',
|
|
38
|
+
'times',
|
|
39
|
+
'round',
|
|
40
|
+
'slugify',
|
|
41
|
+
'join',
|
|
42
|
+
'last',
|
|
43
|
+
'first',
|
|
44
|
+
'reverse',
|
|
45
|
+
'sort',
|
|
46
|
+
'sort_natural',
|
|
47
|
+
'size',
|
|
48
|
+
'map',
|
|
49
|
+
'sum',
|
|
50
|
+
'compact',
|
|
51
|
+
'concat',
|
|
52
|
+
'push',
|
|
53
|
+
'unshift',
|
|
54
|
+
'pop',
|
|
55
|
+
'shift',
|
|
56
|
+
'slice',
|
|
57
|
+
'uniq',
|
|
58
|
+
'lstrip',
|
|
59
|
+
'downcase',
|
|
60
|
+
'upcase',
|
|
61
|
+
'remove',
|
|
62
|
+
'remove_first',
|
|
63
|
+
'remove_last',
|
|
64
|
+
'rstrip',
|
|
65
|
+
'split',
|
|
66
|
+
'strip',
|
|
67
|
+
'strip_newlines',
|
|
68
|
+
'squish',
|
|
69
|
+
'capitalize',
|
|
70
|
+
'replace',
|
|
71
|
+
'replace_first',
|
|
72
|
+
'replace_last',
|
|
73
|
+
'truncate',
|
|
74
|
+
'truncatewords',
|
|
75
|
+
'normalize_whitespace',
|
|
76
|
+
'number_of_words',
|
|
77
|
+
'array_to_sentence_string',
|
|
78
|
+
'base64_encode',
|
|
79
|
+
'sha256',
|
|
80
|
+
'hmac_sha256',
|
|
81
|
+
'default',
|
|
82
|
+
'raw',
|
|
83
|
+
'jsonify',
|
|
84
|
+
'to_integer',
|
|
85
|
+
'json',
|
|
86
|
+
'inspect',
|
|
87
|
+
] as const;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ForTag, TypeGuards, Value, toValueSync } from 'liquidjs';
|
|
2
|
+
import type { Filter, Template, ValueToken } from 'liquidjs';
|
|
3
|
+
import { promptPlaceholders } from './constants.ts';
|
|
4
|
+
import { promptEngine } from './prompt-engine.ts';
|
|
5
|
+
|
|
6
|
+
const known: ReadonlySet<string> = new Set(promptPlaceholders);
|
|
7
|
+
|
|
8
|
+
// Why the template cannot render, or null when it can: a syntax error, a tag or filter the engine
|
|
9
|
+
// does not have, a name that is no placeholder or is read from a value, a for loop over anything
|
|
10
|
+
// but a bare placeholder, a range, or a field read on any value.
|
|
11
|
+
export function findPromptTemplateFault(template: string): string | null {
|
|
12
|
+
const parsed = parseTemplate(template);
|
|
13
|
+
|
|
14
|
+
if (typeof parsed === 'string') {
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const templates = collectTemplates(parsed);
|
|
19
|
+
|
|
20
|
+
// Liquid reads `[title]` as the variable whose name is the value of `title`. Its static analysis
|
|
21
|
+
// reports that read as `title`, so the check walks the tokens itself.
|
|
22
|
+
const tokens = templates
|
|
23
|
+
.flatMap((item) => (item.arguments ? [...item.arguments()] : []))
|
|
24
|
+
.flatMap((argument) => collectValueTokens(argument))
|
|
25
|
+
.flatMap((token) => collectNestedTokens(token));
|
|
26
|
+
|
|
27
|
+
if (tokens.some((token) => isNameFromValue(token))) {
|
|
28
|
+
return 'the template reads a variable whose name is a value, such as [title]; write the name out, as {{ title }}';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const unknown = [
|
|
32
|
+
...new Set(
|
|
33
|
+
promptEngine.globalVariableSegmentsSync(parsed).map((segments) => String(segments[0])),
|
|
34
|
+
),
|
|
35
|
+
].filter((name) => !known.has(name));
|
|
36
|
+
|
|
37
|
+
if (unknown.length > 0) {
|
|
38
|
+
return `the template names ${formatNames(unknown)}, which is not a placeholder; the placeholders are ${promptPlaceholders.join(', ')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const loops = templates.filter((item) => item instanceof ForTag);
|
|
42
|
+
|
|
43
|
+
// The for tag keeps its own `forloop` object under that name, and a loop variable of the same
|
|
44
|
+
// name replaces it, so the tag throws on its next turn.
|
|
45
|
+
if (loops.some((loop) => loop.variable === 'forloop')) {
|
|
46
|
+
return 'the template names a loop variable "forloop", which the for tag uses itself';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The for tag drops the filters after its list without running them.
|
|
50
|
+
if (loops.some((loop) => loop.token.args.includes('|'))) {
|
|
51
|
+
return 'the template filters the list of a for loop, which the for tag ignores';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const collections = loops
|
|
55
|
+
.map((loop) => loop.collection)
|
|
56
|
+
.filter((collection) => !isBareName(collection));
|
|
57
|
+
|
|
58
|
+
if (collections.length > 0) {
|
|
59
|
+
return `the template loops over ${formatNames(collections.map((collection) => collection.getText()))}, but a for loop loops only over a placeholder`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Liquid builds a range as a list in memory, so `(1..10000000000)` runs out of memory at claim.
|
|
63
|
+
if (tokens.some((token) => TypeGuards.isRangeToken(token))) {
|
|
64
|
+
return 'the template builds a range, such as (1..3), which a prompt has no use for';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Under `strictVariables` a missing field throws. Each value is a string, and a loop over a
|
|
68
|
+
// string yields that string, so no field is safe to read.
|
|
69
|
+
const fields = promptEngine
|
|
70
|
+
.variableSegmentsSync(parsed)
|
|
71
|
+
.filter((segments) => segments.length > 1);
|
|
72
|
+
|
|
73
|
+
if (fields.length > 0) {
|
|
74
|
+
return `the template reads ${formatNames(fields.map((segments) => segments.join('.')))}, but each value is plain text with no fields`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseTemplate(template: string): Template[] | string {
|
|
81
|
+
try {
|
|
82
|
+
return promptEngine.parse(template);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
|
|
86
|
+
return `the template does not parse: ${message}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectTemplates(templates: readonly Template[]): Template[] {
|
|
91
|
+
return templates.flatMap((template) => {
|
|
92
|
+
const children = template.children ? toValueSync(template.children(false, true)) : [];
|
|
93
|
+
|
|
94
|
+
return [template, ...collectTemplates(children)];
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function collectValueTokens(argument: Value | ValueToken): ValueToken[] {
|
|
99
|
+
if (!(argument instanceof Value)) {
|
|
100
|
+
return [argument];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [
|
|
104
|
+
...argument.initial.postfix.filter((token) => TypeGuards.isValueToken(token)),
|
|
105
|
+
...argument.filters.flatMap((filter) => filter.args.flatMap((arg) => toArgTokens(arg))),
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toArgTokens(arg: Filter['args'][number]): ValueToken[] {
|
|
110
|
+
if (!Array.isArray(arg)) {
|
|
111
|
+
return [arg];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return arg[1] ? [arg[1]] : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function collectNestedTokens(token: ValueToken): ValueToken[] {
|
|
118
|
+
const children: ValueToken[] = [];
|
|
119
|
+
|
|
120
|
+
if (TypeGuards.isRangeToken(token)) {
|
|
121
|
+
children.push(token.lhs, token.rhs);
|
|
122
|
+
} else if (TypeGuards.isFilteredValueToken(token)) {
|
|
123
|
+
children.push(
|
|
124
|
+
...token.initial.postfix.filter((item) => TypeGuards.isValueToken(item)),
|
|
125
|
+
...token.filters.flatMap((filter) => filter.args.flatMap((arg) => toArgTokens(arg))),
|
|
126
|
+
);
|
|
127
|
+
} else if (TypeGuards.isPropertyAccessToken(token)) {
|
|
128
|
+
children.push(...token.props.filter((prop) => TypeGuards.isPropertyAccessToken(prop)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return [token, ...children.flatMap((child) => collectNestedTokens(child))];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isNameFromValue(token: ValueToken): boolean {
|
|
135
|
+
return (
|
|
136
|
+
TypeGuards.isPropertyAccessToken(token) && TypeGuards.isPropertyAccessToken(token.props[0])
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatNames(names: readonly string[]): string {
|
|
141
|
+
return names.map((name) => `"${name}"`).join(', ');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isBareName(collection: ForTag['collection']): boolean {
|
|
145
|
+
return (
|
|
146
|
+
TypeGuards.isPropertyAccessToken(collection) &&
|
|
147
|
+
collection.variable === undefined &&
|
|
148
|
+
collection.props.length === 1 &&
|
|
149
|
+
TypeGuards.isWordToken(collection.props[0])
|
|
150
|
+
);
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Liquid } from 'liquidjs';
|
|
2
|
+
import { promptFilters, promptTags } from './constants.ts';
|
|
3
|
+
|
|
4
|
+
// An empty `templates` map keeps Liquid off the file system. `jsTruthy` makes the empty string
|
|
5
|
+
// false, as Liquid's own truthiness counts only nil and false as false. No `outputEscape`: a
|
|
6
|
+
// prompt is plain text, so a value reaches the agent as written.
|
|
7
|
+
export const promptEngine = new Liquid({
|
|
8
|
+
templates: {},
|
|
9
|
+
jsTruthy: true,
|
|
10
|
+
strictVariables: true,
|
|
11
|
+
strictFilters: true,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const tags: ReadonlySet<string> = new Set(promptTags);
|
|
15
|
+
const filters: ReadonlySet<string> = new Set(promptFilters);
|
|
16
|
+
|
|
17
|
+
for (const tag of Object.keys(promptEngine.tags).filter((name) => !tags.has(name))) {
|
|
18
|
+
Reflect.deleteProperty(promptEngine.tags, tag);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const filter of Object.keys(promptEngine.filters).filter((name) => !filters.has(name))) {
|
|
22
|
+
Reflect.deleteProperty(promptEngine.filters, filter);
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import invariant from 'tiny-invariant';
|
|
2
|
+
import { promptEngine } from './prompt-engine.ts';
|
|
3
|
+
import type { PromptValues } from './types.ts';
|
|
4
|
+
|
|
5
|
+
// Liquid writes each value as text and never parses it again, so a value that holds `{{ }}` or
|
|
6
|
+
// `{% %}` reaches the agent as written.
|
|
7
|
+
export function renderPromptTemplate(template: string, values: PromptValues): string {
|
|
8
|
+
const rendered: unknown = promptEngine.parseAndRenderSync(template, values);
|
|
9
|
+
|
|
10
|
+
invariant(typeof rendered === 'string', 'a Liquid render returns text');
|
|
11
|
+
|
|
12
|
+
return rendered;
|
|
13
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { promptPlaceholders } from './constants.ts';
|
|
2
|
+
|
|
3
|
+
export type PromptPlaceholder = (typeof promptPlaceholders)[number];
|
|
4
|
+
|
|
5
|
+
// Each value is plain text. An empty section is the empty string, which `{% if %}` reads as false.
|
|
6
|
+
export type PromptValues = Readonly<Record<PromptPlaceholder, string>>;
|