bare-tui-form 0.0.0 → 0.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/README.md +525 -0
- package/fields/action.js +63 -0
- package/fields/base.js +213 -0
- package/fields/confirm.js +46 -0
- package/fields/constant.js +41 -0
- package/fields/file.js +129 -0
- package/fields/list.js +158 -0
- package/fields/multiselect.js +97 -0
- package/fields/number.js +60 -0
- package/fields/radio.js +32 -0
- package/fields/section.js +71 -0
- package/fields/select.js +46 -0
- package/fields/text.js +35 -0
- package/fields/textarea.js +43 -0
- package/form.js +1028 -0
- package/harden.js +173 -0
- package/index.js +86 -0
- package/package.json +52 -1
- package/prompt.js +107 -0
- package/schema.js +620 -0
- package/structural.js +62 -0
- package/theme.js +86 -0
package/harden.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// harden — the security boundary for untrusted JSON Schema.
|
|
2
|
+
//
|
|
3
|
+
// fromSchema turns externally-supplied data (an LLM- or peer-authored schema,
|
|
4
|
+
// plus rehydration `formData`) into compiled regexes and strings painted onto a
|
|
5
|
+
// terminal. That makes it the one place in this library that ingests data an
|
|
6
|
+
// attacker may control, so it's the one place we treat as hostile. These
|
|
7
|
+
// primitives are the defenses; schema.js applies them at the boundary.
|
|
8
|
+
//
|
|
9
|
+
// The posture is secure-by-default: limits are enforced, control characters are
|
|
10
|
+
// stripped, and risky regexes are refused. A caller who genuinely trusts the
|
|
11
|
+
// source opts back into full fidelity with { trusted: true } / { limits }.
|
|
12
|
+
|
|
13
|
+
// Caps that bound how much work a single schema can cause. Generous enough for
|
|
14
|
+
// real forms, low enough that a malicious schema can't exhaust memory/CPU.
|
|
15
|
+
const DEFAULT_LIMITS = {
|
|
16
|
+
maxFields: 200, // total leaf fields across the whole (possibly nested) schema
|
|
17
|
+
maxDepth: 8, // how deeply objects may nest (a recursion / stack-overflow bound)
|
|
18
|
+
maxEnum: 1000, // entries in an enum / items.enum (and rehydrated arrays)
|
|
19
|
+
maxBranches: 20, // branches in a oneOf/anyOf variant selector
|
|
20
|
+
maxArrayItems: 100, // entries an array-of-objects can hold (add + rehydrate)
|
|
21
|
+
maxStringLength: 100000, // ceiling for a text field's charLimit
|
|
22
|
+
maxPatternLength: 1000, // source length of a `pattern` regex
|
|
23
|
+
maxTextLength: 5000, // length any single rendered string is truncated to
|
|
24
|
+
maxInputTested: 10000 // input length a `pattern` is tested against
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Property names that, used as an object key, can corrupt the prototype chain
|
|
28
|
+
// when the collected values object is built (out[key] = …). Refused outright.
|
|
29
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
30
|
+
|
|
31
|
+
// C0 controls (incl. ESC 0x1b, the start of every ANSI/OSC sequence), DEL, and
|
|
32
|
+
// C1 controls (incl. CSI 0x9b). Anything in here, rendered to a terminal, can
|
|
33
|
+
// move the cursor, rewrite the screen, set the window title, or worse — so we
|
|
34
|
+
// strip it from every schema-derived string before it can reach the output.
|
|
35
|
+
// Built from an ASCII string literal so no control bytes live in this source.
|
|
36
|
+
const CONTROL_CHARS = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f]', 'g')
|
|
37
|
+
|
|
38
|
+
// Coerce to a string, strip terminal-control characters, and truncate. Returns
|
|
39
|
+
// the value untouched when it's null/undefined so "absent" stays absent.
|
|
40
|
+
function cleanText(value, max = DEFAULT_LIMITS.maxTextLength) {
|
|
41
|
+
if (value === null || value === undefined) return value
|
|
42
|
+
let s = String(value).replace(CONTROL_CHARS, '')
|
|
43
|
+
if (max > 0 && s.length > max) s = s.slice(0, max)
|
|
44
|
+
return s
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A finite number, or undefined. JSON Schema constraints (minimum, maxLength, …)
|
|
48
|
+
// are trusted only when they really are finite numbers; "5", NaN, Infinity, and
|
|
49
|
+
// objects are ignored rather than fed into comparisons or RegExp/charLimit math.
|
|
50
|
+
function safeNumber(n) {
|
|
51
|
+
return typeof n === 'number' && Number.isFinite(n) ? n : undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Throw if a property name would be dangerous as an object key.
|
|
55
|
+
function assertSafeKey(name) {
|
|
56
|
+
if (FORBIDDEN_KEYS.has(name)) {
|
|
57
|
+
throw new Error(`fromSchema: property name "${name}" is not allowed`)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Heuristic ReDoS guard. The catastrophic-backtracking family is an unbounded
|
|
62
|
+
// quantifier applied to a group that itself contains an unbounded quantifier:
|
|
63
|
+
// (a+)+, (a*)*, (.*)*, ([a-z]+)* … We scan for exactly that shape. The check is
|
|
64
|
+
// deliberately conservative — it may refuse some safe patterns, in which case
|
|
65
|
+
// the pattern constraint is dropped (the form still works, just without that
|
|
66
|
+
// one client-side check) and a warning is recorded. It is NOT a general
|
|
67
|
+
// safe-regex prover; { trusted: true } bypasses it for known-good schemas.
|
|
68
|
+
function isSafeRegexSource(src) {
|
|
69
|
+
let escaped = false
|
|
70
|
+
let inClass = false
|
|
71
|
+
let prevWasGroupClose = false
|
|
72
|
+
let closedGroupUnbounded = false
|
|
73
|
+
// Per currently-open group: did it contain an unbounded quantifier?
|
|
74
|
+
const groupUnbounded = []
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < src.length; i++) {
|
|
77
|
+
const c = src[i]
|
|
78
|
+
|
|
79
|
+
if (escaped) {
|
|
80
|
+
escaped = false
|
|
81
|
+
prevWasGroupClose = false
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (c === '\\') {
|
|
85
|
+
escaped = true
|
|
86
|
+
prevWasGroupClose = false
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (inClass) {
|
|
90
|
+
if (c === ']') inClass = false
|
|
91
|
+
prevWasGroupClose = false
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (c === '[') {
|
|
95
|
+
inClass = true
|
|
96
|
+
prevWasGroupClose = false
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (c === '(') {
|
|
100
|
+
groupUnbounded.push(false)
|
|
101
|
+
prevWasGroupClose = false
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
if (c === ')') {
|
|
105
|
+
closedGroupUnbounded = groupUnbounded.pop() || false
|
|
106
|
+
prevWasGroupClose = true
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (c === '*' || c === '+' || c === '{') {
|
|
111
|
+
const unbounded = c === '{' ? isUnboundedBrace(src, i) : true
|
|
112
|
+
// A nested unbounded quantifier: refuse.
|
|
113
|
+
if (unbounded && prevWasGroupClose && closedGroupUnbounded) return false
|
|
114
|
+
// Mark the enclosing group as containing an unbounded quantifier.
|
|
115
|
+
if (unbounded && groupUnbounded.length > 0) {
|
|
116
|
+
groupUnbounded[groupUnbounded.length - 1] = true
|
|
117
|
+
}
|
|
118
|
+
prevWasGroupClose = false
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
prevWasGroupClose = false
|
|
123
|
+
}
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Is the `{…}` quantifier starting at `i` open-ended (`{n,}`) — the only brace
|
|
128
|
+
// form that backtracks unboundedly? `{n}` and `{n,m}` are bounded.
|
|
129
|
+
function isUnboundedBrace(src, i) {
|
|
130
|
+
const close = src.indexOf('}', i)
|
|
131
|
+
if (close === -1) return false // a literal '{', not a quantifier
|
|
132
|
+
const body = src.slice(i + 1, close)
|
|
133
|
+
return /^\d+,\s*$/.test(body) // n, with nothing after the comma
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Compile a `pattern` into a bounded tester, or return why it was refused.
|
|
137
|
+
// Returns { test } on success or { reason } when dropped. The tester caps the
|
|
138
|
+
// input length it inspects so even a linear-time regex can't be driven to
|
|
139
|
+
// pathological cost by a huge pasted value.
|
|
140
|
+
function compilePattern(source, opts = {}) {
|
|
141
|
+
const trusted = opts.trusted === true
|
|
142
|
+
const maxPatternLength = opts.maxPatternLength ?? DEFAULT_LIMITS.maxPatternLength
|
|
143
|
+
const maxInputTested = opts.maxInputTested ?? DEFAULT_LIMITS.maxInputTested
|
|
144
|
+
|
|
145
|
+
if (typeof source !== 'string' || source === '') {
|
|
146
|
+
return { reason: 'pattern ignored (not a string)' }
|
|
147
|
+
}
|
|
148
|
+
if (source.length > maxPatternLength) {
|
|
149
|
+
return { reason: `pattern ignored (longer than ${maxPatternLength} chars)` }
|
|
150
|
+
}
|
|
151
|
+
if (!trusted && !isSafeRegexSource(source)) {
|
|
152
|
+
return { reason: 'pattern ignored (rejected as a possible ReDoS)' }
|
|
153
|
+
}
|
|
154
|
+
let re
|
|
155
|
+
try {
|
|
156
|
+
re = new RegExp(source)
|
|
157
|
+
} catch {
|
|
158
|
+
return { reason: 'pattern ignored (not a valid regular expression)' }
|
|
159
|
+
}
|
|
160
|
+
const test = (v) => re.test(String(v).slice(0, maxInputTested))
|
|
161
|
+
return { test }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
DEFAULT_LIMITS,
|
|
166
|
+
FORBIDDEN_KEYS,
|
|
167
|
+
cleanText,
|
|
168
|
+
safeNumber,
|
|
169
|
+
assertSafeKey,
|
|
170
|
+
isSafeRegexSource,
|
|
171
|
+
isUnboundedBrace,
|
|
172
|
+
compilePattern
|
|
173
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// bare-tui-form — a declarative form builder for bare-tui.
|
|
2
|
+
//
|
|
3
|
+
// Built entirely on bare-tui's field controls (textinput, textarea, select,
|
|
4
|
+
// radio, checkbox) and its focus ring: this package adds the orchestration
|
|
5
|
+
// layer — labels, descriptions, per-field validation, focus movement, and
|
|
6
|
+
// submission — that a form needs but a control library deliberately leaves out.
|
|
7
|
+
//
|
|
8
|
+
// const form = require('bare-tui-form')
|
|
9
|
+
//
|
|
10
|
+
// const f = form.create({
|
|
11
|
+
// title: 'Sign up',
|
|
12
|
+
// fields: [
|
|
13
|
+
// form.text({ name: 'name', label: 'Name', required: true }),
|
|
14
|
+
// form.text({ name: 'email', label: 'Email', validate: isEmail }),
|
|
15
|
+
// form.select({ name: 'plan', label: 'Plan', options: ['free', 'pro'] }),
|
|
16
|
+
// form.confirm({ name: 'tos', label: 'Accept terms', required: true })
|
|
17
|
+
// ]
|
|
18
|
+
// })
|
|
19
|
+
//
|
|
20
|
+
// const values = await form.run(f) // { name, email, plan, tos } | null
|
|
21
|
+
//
|
|
22
|
+
// Fields can also be plain `{ type, name, … }` objects, which is the shape a
|
|
23
|
+
// JSON-Schema mapper will produce — see the README for that planned path.
|
|
24
|
+
const { create, run, Form, fromDef } = require('./form')
|
|
25
|
+
const { fromSchema } = require('./schema')
|
|
26
|
+
const { defaultTheme, frame, frames } = require('./theme')
|
|
27
|
+
|
|
28
|
+
const { text, TextField } = require('./fields/text')
|
|
29
|
+
const { textarea, TextareaField } = require('./fields/textarea')
|
|
30
|
+
const { number, NumberField } = require('./fields/number')
|
|
31
|
+
const { select, SelectField } = require('./fields/select')
|
|
32
|
+
const { radio, RadioField } = require('./fields/radio')
|
|
33
|
+
const { confirm, ConfirmField } = require('./fields/confirm')
|
|
34
|
+
const { multiselect, MultiSelectField } = require('./fields/multiselect')
|
|
35
|
+
const { file, FileField } = require('./fields/file')
|
|
36
|
+
const { list, ListField } = require('./fields/list')
|
|
37
|
+
const { section, SectionToggleField } = require('./fields/section')
|
|
38
|
+
const { constant, ConstField } = require('./fields/constant')
|
|
39
|
+
const { action, ActionField } = require('./fields/action')
|
|
40
|
+
const { group, array } = require('./structural')
|
|
41
|
+
const { Field } = require('./fields/base')
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
// Form
|
|
45
|
+
create, // form.create({ title, fields }) → Form
|
|
46
|
+
run, // form.run(form, programOpts?) → Promise<values | null>
|
|
47
|
+
Form,
|
|
48
|
+
fromDef, // fromDef({ type, … }) → field instance (the schema-friendly hook)
|
|
49
|
+
fromSchema, // fromSchema(jsonSchema, opts?) → Form built from a JSON Schema
|
|
50
|
+
defaultTheme, // the default theme object, to spread/extend in form.create({ theme })
|
|
51
|
+
frame, // form.frame({ border, color, background, padding, width }) → a theme.frame fn
|
|
52
|
+
frames, // ready-made frame presets: frames.rounded / .normal / .thick / .double
|
|
53
|
+
|
|
54
|
+
// Field factories
|
|
55
|
+
text,
|
|
56
|
+
textarea,
|
|
57
|
+
number,
|
|
58
|
+
select,
|
|
59
|
+
radio,
|
|
60
|
+
confirm,
|
|
61
|
+
multiselect,
|
|
62
|
+
file, // a type-or-browse path field (filepicker overlay); ui:widget 'file' / 'directory'
|
|
63
|
+
list, // an editable list of scalar text rows (array of string items)
|
|
64
|
+
section, // the optional-section gate (usually produced by fromSchema, not hand-written)
|
|
65
|
+
constant, // a fixed value (JSON Schema const); usually produced by fromSchema
|
|
66
|
+
action, // a focusable button (array add/remove); usually produced by fromSchema
|
|
67
|
+
|
|
68
|
+
// Structural helpers for hand-built forms (nested objects, repeatable arrays)
|
|
69
|
+
group, // form.group({ name, fields, optional }) → a nested object section
|
|
70
|
+
array, // form.array({ name, fields, minItems, maxItems, addable, removable })
|
|
71
|
+
|
|
72
|
+
// Classes (for extension / instanceof)
|
|
73
|
+
Field,
|
|
74
|
+
TextField,
|
|
75
|
+
TextareaField,
|
|
76
|
+
NumberField,
|
|
77
|
+
SelectField,
|
|
78
|
+
RadioField,
|
|
79
|
+
ConfirmField,
|
|
80
|
+
MultiSelectField,
|
|
81
|
+
FileField,
|
|
82
|
+
ListField,
|
|
83
|
+
SectionToggleField,
|
|
84
|
+
ConstField,
|
|
85
|
+
ActionField
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1,52 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "bare-tui-form",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "A declarative form builder for bare-tui",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./package": "./package.json",
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./harden": "./harden.js",
|
|
10
|
+
"./prompt": "./prompt.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"package.json",
|
|
14
|
+
"index.js",
|
|
15
|
+
"form.js",
|
|
16
|
+
"theme.js",
|
|
17
|
+
"schema.js",
|
|
18
|
+
"harden.js",
|
|
19
|
+
"prompt.js",
|
|
20
|
+
"structural.js",
|
|
21
|
+
"fields",
|
|
22
|
+
"docs",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"bare-tui": "^0.0.3"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"bare-fs": "^4.7.1",
|
|
30
|
+
"bare-path": "^3.0.0",
|
|
31
|
+
"bare-stream": "^2.13.1",
|
|
32
|
+
"brittle": "^3.19.0",
|
|
33
|
+
"lunte": "^1.2.0",
|
|
34
|
+
"prettier": "^3.6.2",
|
|
35
|
+
"prettier-config-holepunch": "^2.0.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"format": "prettier --write . && lunte --fix",
|
|
39
|
+
"lint": "prettier --check . && lunte",
|
|
40
|
+
"test": "brittle-bare --coverage test/index.js"
|
|
41
|
+
},
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/holepunchto/bare-tui-form.git"
|
|
45
|
+
},
|
|
46
|
+
"author": "Holepunch",
|
|
47
|
+
"license": "Apache-2.0",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/holepunchto/bare-tui-form/issues"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/holepunchto/bare-tui-form"
|
|
52
|
+
}
|
package/prompt.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// prompt.js — embeddable instructions that teach an LLM to emit a JSON Schema
|
|
2
|
+
// (and, optionally, a uiSchema) that bare-tui-form can render into a terminal
|
|
3
|
+
// form and collect from a user.
|
|
4
|
+
//
|
|
5
|
+
// This is meant to be *imported into your app* and dropped into your model's
|
|
6
|
+
// system prompt — not read from this repo at runtime — so an embedded LLM can
|
|
7
|
+
// drive form collection dynamically:
|
|
8
|
+
//
|
|
9
|
+
// const { build } = require('bare-tui-form/prompt')
|
|
10
|
+
//
|
|
11
|
+
// const system = build() // flat fields only (smallest)
|
|
12
|
+
// const system = build({ nesting: true }) // + objects/arrays/oneOf/if
|
|
13
|
+
// const system = build({ uiSchema: true }) // + presentation, two outputs
|
|
14
|
+
//
|
|
15
|
+
// Then feed the model's JSON straight into fromSchema:
|
|
16
|
+
//
|
|
17
|
+
// const { fromSchema } = require('bare-tui-form')
|
|
18
|
+
// const f = fromSchema(JSON.parse(modelJson)) // (parse uiSchema too if used)
|
|
19
|
+
//
|
|
20
|
+
// It is written for *small* models, so the exported strings are terse. The
|
|
21
|
+
// layers are separate exports (CORE / NESTING / UI_SCHEMA) so you only spend
|
|
22
|
+
// tokens on what your forms actually use; build() assembles them plus the
|
|
23
|
+
// matching output contract. The verbose comments here are for you, the
|
|
24
|
+
// developer — they are not part of any exported string.
|
|
25
|
+
//
|
|
26
|
+
// SECURITY: every string the model produces is untrusted and fromSchema is
|
|
27
|
+
// hardened for exactly that (see harden.js / schema.js). The prompt does not
|
|
28
|
+
// relax those defenses; it only shapes what a cooperative model emits.
|
|
29
|
+
|
|
30
|
+
// What the form can do with a flat object schema — the 80% case.
|
|
31
|
+
const CORE = `You collect data from a person with a form. You describe the form as ONE JSON Schema object; it is rendered in a terminal and the answers come back as JSON matching it.
|
|
32
|
+
|
|
33
|
+
Rules:
|
|
34
|
+
- The schema root is {"type":"object","properties":{...}}. Each property is one field.
|
|
35
|
+
- Give each property a short "title" (the on-screen label). Add a one-line "description" only when it genuinely helps.
|
|
36
|
+
- Put mandatory property names in the root "required" array.
|
|
37
|
+
- "default" pre-fills a field.
|
|
38
|
+
|
|
39
|
+
Field type comes from the property's "type":
|
|
40
|
+
- "string" -> text box. Checks: "minLength", "maxLength", "pattern" (regex), "format":"email" or "uri".
|
|
41
|
+
- "integer"/"number" -> number box; "minimum"/"maximum" bound it.
|
|
42
|
+
- "boolean" -> a yes/no checkbox.
|
|
43
|
+
- "string" with "enum":[...] -> single-choice menu. Add "enumNames":[...] (same order) for nicer labels.
|
|
44
|
+
|
|
45
|
+
Stay minimal: only the properties you need, short titles, no filler.
|
|
46
|
+
|
|
47
|
+
Example -- "sign up: name, email, plan":
|
|
48
|
+
{"type":"object","title":"Sign up","required":["name","email"],"properties":{"name":{"type":"string","title":"Full name","minLength":1},"email":{"type":"string","title":"Email","format":"email"},"plan":{"type":"string","title":"Plan","enum":["free","pro"],"enumNames":["Free","Pro"],"default":"free"},"news":{"type":"boolean","title":"Email me updates"}}}`
|
|
49
|
+
|
|
50
|
+
// Structure: nested objects, repeatable/multi arrays, oneOf/anyOf, if/then/else.
|
|
51
|
+
// Append only when your forms need shape beyond a flat list.
|
|
52
|
+
const NESTING = `Structure (use only when the data needs it):
|
|
53
|
+
- Nested object: a property with "type":"object" and its own "properties" -> a sub-section. In the parent "required" it is always shown; otherwise it is optional and the user opts in with a checkbox.
|
|
54
|
+
- Pick many: "type":"array","items":{"enum":[...]} -> a multi-select list.
|
|
55
|
+
- Repeatable group: "type":"array","items":{"type":"object","properties":{...}} -> the user adds/removes entries; "minItems"/"maxItems" bound the count.
|
|
56
|
+
- One of several shapes: "oneOf" (or "anyOf") = a list of branches. Make them ALL scalar ({"const":x} or {"enum":[...]}) -> a value picker, OR ALL objects ({"properties":{...}}) -> the user picks a branch and fills it. Never mix the two.
|
|
57
|
+
- Conditional fields: "if":{"properties":{"kind":{"const":"card"}}},"then":{"properties":{...}},"else":{"properties":{...}} -> the then/else fields appear based on another field. "if" may only test "const" or "enum" on sibling properties.
|
|
58
|
+
- Fixed value: "const" on a property -> a non-editable value (useful as a oneOf branch tag).
|
|
59
|
+
|
|
60
|
+
Never emit (these are rejected): arrays of plain strings/numbers (use items.enum or object items), arrays inside arrays, "allOf", "$ref".`
|
|
61
|
+
|
|
62
|
+
// Presentation layer. Mirrors RJSF's uiSchema. Changes the OUTPUT CONTRACT:
|
|
63
|
+
// the model must now emit two objects. build({ uiSchema: true }) swaps in
|
|
64
|
+
// BOTH_OUTPUT to say so.
|
|
65
|
+
const UI_SCHEMA = `Presentation: you also emit a second object, a uiSchema, that mirrors the schema's property names. It controls looks only -- never put data or validation in it.
|
|
66
|
+
|
|
67
|
+
Per property (each "ui:KEY" may instead sit in "ui:options":{KEY:...} without the prefix):
|
|
68
|
+
- "ui:widget": "textarea" (multi-line; rows via "ui:options":{"rows":N}), "password" (masked), "radio" (show an enum as radio buttons), "hidden" (collected, not shown).
|
|
69
|
+
- "ui:help" (a hint line), "ui:placeholder", "ui:title"/"ui:description" (override the schema's), "ui:autofocus":true, "ui:readonly":true, "ui:options":{"label":false} (hide the label).
|
|
70
|
+
On the root: "ui:order":["a","b","*"] orders fields ("*" = everything else).
|
|
71
|
+
On a repeatable group: nest item presentation under "items"; lock the count with "ui:options":{"addable":false} or {"removable":false}.
|
|
72
|
+
|
|
73
|
+
Example pairing:
|
|
74
|
+
schema: {"type":"object","properties":{"pw":{"type":"string","title":"Password"},"bio":{"type":"string","title":"Bio"}}}
|
|
75
|
+
uiSchema: {"ui:order":["pw","bio"],"pw":{"ui:widget":"password"},"bio":{"ui:widget":"textarea","ui:options":{"rows":4}}}`
|
|
76
|
+
|
|
77
|
+
const INTRO = `You design a terminal form to gather information from a user.`
|
|
78
|
+
|
|
79
|
+
// Output contracts — one of these closes the prompt so the model knows the
|
|
80
|
+
// exact shape to return.
|
|
81
|
+
const SCHEMA_OUTPUT = `Output: emit ONLY the JSON Schema object. No prose, no markdown, no code fence.`
|
|
82
|
+
const BOTH_OUTPUT = `Output: emit TWO JSON objects, the JSON Schema first and the uiSchema second, separated by a line containing only ---. No prose, no code fence.`
|
|
83
|
+
|
|
84
|
+
// Assemble a system prompt from the layers you want.
|
|
85
|
+
// build() -> intro + CORE + schema-only output
|
|
86
|
+
// build({ nesting: true }) -> + NESTING
|
|
87
|
+
// build({ uiSchema: true }) -> + UI_SCHEMA + two-object output
|
|
88
|
+
// Pass { intro: false } to drop the framing line if you supply your own.
|
|
89
|
+
function build(opts = {}) {
|
|
90
|
+
const parts = []
|
|
91
|
+
if (opts.intro !== false) parts.push(INTRO)
|
|
92
|
+
parts.push(CORE)
|
|
93
|
+
if (opts.nesting) parts.push(NESTING)
|
|
94
|
+
if (opts.uiSchema) parts.push(UI_SCHEMA)
|
|
95
|
+
parts.push(opts.uiSchema ? BOTH_OUTPUT : SCHEMA_OUTPUT)
|
|
96
|
+
return parts.join('\n\n')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
build, // build({ nesting?, uiSchema?, intro? }) -> assembled system prompt
|
|
101
|
+
CORE, // flat object schema: types, titles, required, enums, validation
|
|
102
|
+
NESTING, // nested objects, arrays, oneOf/anyOf, if/then/else, const
|
|
103
|
+
UI_SCHEMA, // uiSchema presentation layer (implies the two-object output)
|
|
104
|
+
INTRO, // one-line framing sentence
|
|
105
|
+
SCHEMA_OUTPUT, // output contract: schema only
|
|
106
|
+
BOTH_OUTPUT // output contract: schema + uiSchema, --- separated
|
|
107
|
+
}
|