poetry-agent 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +51 -0
- data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
- data/app/javascript/poetry/agent/adapter.js +77 -0
- data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
- data/app/javascript/poetry/agent/index.js +41 -0
- data/app/javascript/poetry/agent/stream_actions.js +65 -0
- data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
- data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
- data/config/controllers_manifest.json +82 -0
- data/config/importmap.rb +10 -0
- data/exe/poetry-agent +28 -0
- data/lib/poetry/agent/a2ui/catalog.rb +289 -0
- data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
- data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
- data/lib/poetry/agent/a2ui/checks.rb +45 -0
- data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
- data/lib/poetry/agent/a2ui/expression.rb +175 -0
- data/lib/poetry/agent/a2ui/functions.rb +417 -0
- data/lib/poetry/agent/a2ui/markdown.rb +63 -0
- data/lib/poetry/agent/a2ui/pointer.rb +113 -0
- data/lib/poetry/agent/a2ui/protocol.rb +12 -0
- data/lib/poetry/agent/a2ui/renderer.rb +242 -0
- data/lib/poetry/agent/a2ui/session.rb +302 -0
- data/lib/poetry/agent/a2ui/streams.rb +82 -0
- data/lib/poetry/agent/a2ui/surface.rb +352 -0
- data/lib/poetry/agent/a2ui.rb +48 -0
- data/lib/poetry/agent/agui/client.rb +69 -0
- data/lib/poetry/agent/agui/json_patch.rb +137 -0
- data/lib/poetry/agent/agui/relay.rb +105 -0
- data/lib/poetry/agent/agui/run_input.rb +83 -0
- data/lib/poetry/agent/agui/sse.rb +97 -0
- data/lib/poetry/agent/agui/transcript.rb +540 -0
- data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
- data/lib/poetry/agent/agui.rb +87 -0
- data/lib/poetry/agent/config.rb +49 -0
- data/lib/poetry/agent/engine.rb +37 -0
- data/lib/poetry/agent/mcp/bundled.rb +54 -0
- data/lib/poetry/agent/mcp/http.rb +89 -0
- data/lib/poetry/agent/mcp/server.rb +962 -0
- data/lib/poetry/agent/version.rb +8 -0
- data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
- data/lib/poetry/agent/webmcp.rb +37 -0
- data/lib/poetry/agent.rb +66 -0
- data/lib/poetry-agent.rb +4 -0
- metadata +117 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "active_support"
|
|
6
|
+
require "active_support/number_helper"
|
|
7
|
+
require_relative "protocol"
|
|
8
|
+
|
|
9
|
+
module Poetry
|
|
10
|
+
module Agent
|
|
11
|
+
module A2UI
|
|
12
|
+
# The renderer's function registry: named functions an agent may
|
|
13
|
+
# reference in a component's dynamic values and checks, each with the
|
|
14
|
+
# declaration a catalog document publishes (`functions` and
|
|
15
|
+
# `$defs.anyFunction`). {Functions.basic} holds the spec's basic
|
|
16
|
+
# catalog set - the validators, the formatters, the boolean
|
|
17
|
+
# combinators, `openUrl` - implemented from their descriptions;
|
|
18
|
+
# `@index` is the evaluator's own system function.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# Functions.basic.call("pluralize", { "value" => 2, "one" => "item", "other" => "items" }, evaluator)
|
|
22
|
+
# # => "items"
|
|
23
|
+
class Functions
|
|
24
|
+
# A missing function or a bad argument list.
|
|
25
|
+
class Error < StandardError; end
|
|
26
|
+
|
|
27
|
+
# One declared function.
|
|
28
|
+
Definition = Struct.new(:name, :description, :returns, :params, :required, :activation, :callers, :impl,
|
|
29
|
+
keyword_init: true)
|
|
30
|
+
|
|
31
|
+
# The spec's execution boundaries: who may invoke a function.
|
|
32
|
+
CALLERS = %w[rendererOnly agentOnly rendererOrAgent].freeze
|
|
33
|
+
|
|
34
|
+
# The email shape the basic catalog names.
|
|
35
|
+
EMAIL = /\A[^\s@]+@[^\s@]+\.[^\s@]+\z/
|
|
36
|
+
# Currency symbols (and fraction digits) by ISO 4217 code; other
|
|
37
|
+
# codes render as a code prefix.
|
|
38
|
+
CURRENCIES = { "USD" => ["$", 2], "EUR" => ["€", 2], "GBP" => ["£", 2], "JPY" => ["¥", 0],
|
|
39
|
+
"CNY" => ["CN¥", 2], "INR" => ["₹", 2], "KRW" => ["₩", 0], "BRL" => ["R$", 2],
|
|
40
|
+
"CAD" => ["CA$", 2], "AUD" => ["A$", 2], "MXN" => ["MX$", 2], "CHF" => ["CHF ", 2] }.freeze
|
|
41
|
+
# Unicode TR35 date-pattern fields to strftime.
|
|
42
|
+
DATE_FIELDS = { "yyyy" => "%Y", "yy" => "%y", "y" => "%Y", "MMMM" => "%B", "MMM" => "%b", "MM" => "%m",
|
|
43
|
+
"M" => "%-m", "dd" => "%d", "d" => "%-d", "EEEE" => "%A", "EEE" => "%a", "EE" => "%a",
|
|
44
|
+
"E" => "%a", "HH" => "%H", "H" => "%-H", "hh" => "%I", "h" => "%-I", "mm" => "%M",
|
|
45
|
+
"m" => "%-M", "ss" => "%S", "s" => "%-S", "a" => "%p", "z" => "%Z", "Z" => "%z",
|
|
46
|
+
"X" => "%:z", "XXX" => "%:z" }.freeze
|
|
47
|
+
|
|
48
|
+
# The basic catalog's functions.
|
|
49
|
+
#
|
|
50
|
+
# @return [Functions]
|
|
51
|
+
def self.basic
|
|
52
|
+
@basic ||= new.tap { |registry| Basic.install(registry) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The boolean reading of a value: a ValidationResult by its
|
|
56
|
+
# `valid`, strings by content, nil and false as false.
|
|
57
|
+
#
|
|
58
|
+
# @param value [Object]
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def self.truthy?(value)
|
|
61
|
+
case value
|
|
62
|
+
when Hash then value["valid"] == true
|
|
63
|
+
when String then !value.empty? && !%w[false 0].include?(value.downcase)
|
|
64
|
+
when nil, false then false
|
|
65
|
+
else true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param value [Object]
|
|
70
|
+
# @return [Numeric, nil] the value as a number, when it is one
|
|
71
|
+
def self.number(value)
|
|
72
|
+
case value
|
|
73
|
+
when Numeric then value
|
|
74
|
+
when String
|
|
75
|
+
text = value.strip
|
|
76
|
+
return if text.empty?
|
|
77
|
+
|
|
78
|
+
text.match?(/\A-?\d+\z/) ? text.to_i : Float(text, exception: false)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def initialize
|
|
83
|
+
@definitions = {}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Declares a function.
|
|
87
|
+
#
|
|
88
|
+
# @param name [String]
|
|
89
|
+
# @param description [String]
|
|
90
|
+
# @param returns [String] the spec's `returnType`
|
|
91
|
+
# @param params [Hash{String => Hash}] argument schemas by name
|
|
92
|
+
# @param required [Array<String>] required argument names
|
|
93
|
+
# @param activation [Boolean] whether the call needs a user activation
|
|
94
|
+
# @param callers [String] the execution boundary (`rendererOnly` - the default - keeps the
|
|
95
|
+
# function out of an agent's `callRendererFunction`; `agentOnly` / `rendererOrAgent` admit it)
|
|
96
|
+
# @yieldparam args [Hash{String => Object}] the resolved arguments
|
|
97
|
+
# @yieldparam evaluator [Evaluator] the calling evaluator
|
|
98
|
+
# @return [Functions] self
|
|
99
|
+
# @raise [ArgumentError] for an unknown boundary
|
|
100
|
+
def define(name, description:, returns:, params: {}, required: [], activation: false, # rubocop:disable Metrics/ParameterLists
|
|
101
|
+
callers: "rendererOnly", &impl)
|
|
102
|
+
raise ArgumentError, "callers must be one of #{CALLERS.join(", ")}" unless CALLERS.include?(callers)
|
|
103
|
+
|
|
104
|
+
@definitions[name] = Definition.new(name: name, description: description, returns: returns, params: params,
|
|
105
|
+
required: required, activation: activation, callers: callers, impl: impl)
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param name [String]
|
|
110
|
+
# @return [Boolean] whether an agent may invoke the function through `callRendererFunction`
|
|
111
|
+
def agent_callable?(name)
|
|
112
|
+
definition = @definitions[name]
|
|
113
|
+
!definition.nil? && definition.callers != "rendererOnly"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Array<String>] the declared names
|
|
117
|
+
def names
|
|
118
|
+
@definitions.keys
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param name [String]
|
|
122
|
+
# @return [Boolean]
|
|
123
|
+
def declared?(name)
|
|
124
|
+
@definitions.key?(name)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Calls a function with resolved arguments.
|
|
128
|
+
#
|
|
129
|
+
# @param name [String]
|
|
130
|
+
# @param args [Hash{String => Object}]
|
|
131
|
+
# @param evaluator [Evaluator]
|
|
132
|
+
# @return [Object]
|
|
133
|
+
# @raise [Error] for an unknown function or a missing argument
|
|
134
|
+
def call(name, args, evaluator)
|
|
135
|
+
definition = @definitions[name] or raise Error, "unknown function #{name.inspect}"
|
|
136
|
+
missing = definition.required - args.keys
|
|
137
|
+
raise Error, "#{name}: missing #{missing.join(", ")}" if missing.any?
|
|
138
|
+
|
|
139
|
+
definition.impl.call(args, evaluator)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The catalog document's `functions` section.
|
|
143
|
+
#
|
|
144
|
+
# @return [Hash{String => Hash}]
|
|
145
|
+
def schema
|
|
146
|
+
@definitions.transform_values do |definition|
|
|
147
|
+
call = { "type" => "object",
|
|
148
|
+
"properties" => { "call" => { "const" => definition.name },
|
|
149
|
+
"args" => arguments_schema(definition) },
|
|
150
|
+
"required" => ["call", *("args" if definition.required.any?)] }
|
|
151
|
+
document = { "type" => "object", "description" => definition.description,
|
|
152
|
+
"returnType" => definition.returns, "allowedCallers" => definition.callers }
|
|
153
|
+
document["requiresUserActivation"] = true if definition.activation
|
|
154
|
+
document.merge("allOf" => [{ "$ref" => "#{COMMON_TYPES}FunctionCommon" }, call],
|
|
155
|
+
"unevaluatedProperties" => false)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The catalog document's `$defs.anyFunction`.
|
|
160
|
+
#
|
|
161
|
+
# @return [Hash]
|
|
162
|
+
def any_function
|
|
163
|
+
{ "oneOf" => names.map { |name| { "$ref" => "#/functions/#{name}" } } }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def arguments_schema(definition)
|
|
169
|
+
schema = { "type" => "object", "properties" => definition.params, "unevaluatedProperties" => false }
|
|
170
|
+
schema["required"] = definition.required if definition.required.any?
|
|
171
|
+
schema
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The basic catalog's set, from the spec's descriptions.
|
|
175
|
+
module Basic
|
|
176
|
+
# A parameter schema referencing one of the common dynamic types.
|
|
177
|
+
DYNAMIC = lambda { |kind, description|
|
|
178
|
+
{ "$ref" => "#{COMMON_TYPES}Dynamic#{kind}", "description" => description }
|
|
179
|
+
}
|
|
180
|
+
# The CLDR plural categories `pluralize` selects among.
|
|
181
|
+
PLURAL_CATEGORIES = %w[zero one two few many other].freeze
|
|
182
|
+
# The argument every validator checks.
|
|
183
|
+
CHECKED = { "value" => { "description" => "The value to check." } }.freeze
|
|
184
|
+
|
|
185
|
+
module_function
|
|
186
|
+
|
|
187
|
+
# A boolean list argument (`and`, `or`).
|
|
188
|
+
#
|
|
189
|
+
# @return [Hash]
|
|
190
|
+
def list
|
|
191
|
+
{ "values" => { "type" => "array", "description" => "The values to combine.",
|
|
192
|
+
"items" => { "$ref" => "#{COMMON_TYPES}DynamicBoolean" }, "minItems" => 2 } }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @param registry [Functions]
|
|
196
|
+
# @return [void]
|
|
197
|
+
def install(registry)
|
|
198
|
+
validators(registry)
|
|
199
|
+
formatters(registry)
|
|
200
|
+
combinators(registry)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# @api private
|
|
204
|
+
def validators(registry)
|
|
205
|
+
registry.define("required", description: "Checks that the value is not null, undefined, or empty.",
|
|
206
|
+
returns: "validationResult", required: %w[value], params: CHECKED) do |args, _|
|
|
207
|
+
result(present?(args["value"]))
|
|
208
|
+
end
|
|
209
|
+
pattern = { "pattern" => { "type" => "string", "description" => "The regular expression." } }
|
|
210
|
+
registry.define("regex", description: "Checks that the value matches a regular expression string.",
|
|
211
|
+
returns: "validationResult", required: %w[value pattern],
|
|
212
|
+
params: CHECKED.merge(pattern)) do |args, _|
|
|
213
|
+
regex(args["value"], args["pattern"])
|
|
214
|
+
end
|
|
215
|
+
bounds = { "min" => { "type" => "number", "description" => "The minimum." },
|
|
216
|
+
"max" => { "type" => "number", "description" => "The maximum." } }
|
|
217
|
+
registry.define("length", description: "Checks string length constraints.", returns: "validationResult",
|
|
218
|
+
required: %w[value], params: CHECKED.merge(bounds)) do |args, _|
|
|
219
|
+
result(within?(args["value"].to_s.length, args["min"], args["max"]))
|
|
220
|
+
end
|
|
221
|
+
registry.define("numeric", description: "Checks numeric range constraints.", returns: "validationResult",
|
|
222
|
+
required: %w[value], params: CHECKED.merge(bounds)) do |args, _|
|
|
223
|
+
number = Functions.number(args["value"])
|
|
224
|
+
result(!number.nil? && within?(number, args["min"], args["max"]))
|
|
225
|
+
end
|
|
226
|
+
registry.define("email", description: "Checks that the value is a valid email address.",
|
|
227
|
+
returns: "validationResult", required: %w[value], params: CHECKED) do |args, _|
|
|
228
|
+
result(args["value"].to_s.match?(EMAIL))
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @api private
|
|
233
|
+
def formatters(registry)
|
|
234
|
+
grouping = { "decimals" => DYNAMIC.call("Number", "Fraction digits to show."),
|
|
235
|
+
"grouping" => DYNAMIC.call("Boolean", "Thousands separators (default true).") }
|
|
236
|
+
template = { "value" => DYNAMIC.call("String", "The string with ${} blocks.") }
|
|
237
|
+
registry.define("formatString", description: "Interpolates data model values and function results " \
|
|
238
|
+
"into a string: ${/path}, ${name(arg: value)}.",
|
|
239
|
+
returns: "string", required: %w[value],
|
|
240
|
+
params: template) do |args, evaluator|
|
|
241
|
+
evaluator.stringify(args["value"])
|
|
242
|
+
end
|
|
243
|
+
amount = { "value" => DYNAMIC.call("Number", "The number.") }
|
|
244
|
+
registry.define("formatNumber", description: "Formats a number with grouping and decimal precision.",
|
|
245
|
+
returns: "string", required: %w[value],
|
|
246
|
+
params: amount.merge(grouping)) do |args, _|
|
|
247
|
+
format_number(args)
|
|
248
|
+
end
|
|
249
|
+
money = amount.merge("currency" => DYNAMIC.call("String", "The ISO 4217 code."))
|
|
250
|
+
registry.define("formatCurrency", description: "Formats a number as a currency string.", returns: "string",
|
|
251
|
+
required: %w[value currency],
|
|
252
|
+
params: money.merge(grouping)) do |args, _|
|
|
253
|
+
format_currency(args)
|
|
254
|
+
end
|
|
255
|
+
registry.define("formatDate", description: "Formats a timestamp with a Unicode TR35 pattern " \
|
|
256
|
+
"(yyyy-MM-dd, MMM d, HH:mm).",
|
|
257
|
+
returns: "string", required: %w[value format],
|
|
258
|
+
params: { "value" => DYNAMIC.call("Value", "An ISO 8601 string or epoch."),
|
|
259
|
+
"format" => DYNAMIC.call("String",
|
|
260
|
+
"The TR35 pattern.") }) do |args, _|
|
|
261
|
+
format_date(args["value"], args["format"])
|
|
262
|
+
end
|
|
263
|
+
forms = PLURAL_CATEGORIES.to_h { |category| [category, DYNAMIC.call("String", "The #{category} form.")] }
|
|
264
|
+
forms["value"] = DYNAMIC.call("Number", "The number.")
|
|
265
|
+
registry.define("pluralize", description: "Picks the string for a number's CLDR plural category.",
|
|
266
|
+
returns: "string", required: %w[value other], params: forms) do |args, _|
|
|
267
|
+
pluralize(args)
|
|
268
|
+
end
|
|
269
|
+
registry.define("openUrl", description: "Opens an http(s) URL; the renderer links to it.", returns: "void",
|
|
270
|
+
activation: true, required: %w[url],
|
|
271
|
+
params: { "url" => { "description" => "The URL to open." } }) do |args, _|
|
|
272
|
+
open_url(args["url"])
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @api private
|
|
277
|
+
def combinators(registry)
|
|
278
|
+
registry.define("and", description: "Logical AND of a list of values.", returns: "boolean",
|
|
279
|
+
required: %w[values], params: list) do |args, _|
|
|
280
|
+
Array(args["values"]).all? { |value| Functions.truthy?(value) }
|
|
281
|
+
end
|
|
282
|
+
registry.define("or", description: "Logical OR of a list of values.", returns: "boolean",
|
|
283
|
+
required: %w[values], params: list) do |args, _|
|
|
284
|
+
Array(args["values"]).any? { |value| Functions.truthy?(value) }
|
|
285
|
+
end
|
|
286
|
+
registry.define("not", description: "Logical NOT of a value.", returns: "boolean", required: %w[value],
|
|
287
|
+
params: { "value" => DYNAMIC.call("Boolean", "The value.") }) do |args, _|
|
|
288
|
+
!Functions.truthy?(args["value"])
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# @api private
|
|
293
|
+
def result(valid, code: nil)
|
|
294
|
+
code ? { "valid" => valid, "code" => code } : { "valid" => valid }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# @api private
|
|
298
|
+
def present?(value)
|
|
299
|
+
return false if value.nil?
|
|
300
|
+
return !value.strip.empty? if value.is_a?(String)
|
|
301
|
+
return !value.empty? if value.respond_to?(:empty?)
|
|
302
|
+
|
|
303
|
+
true
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# @api private
|
|
307
|
+
def within?(number, min, max)
|
|
308
|
+
(min.nil? || number >= min) && (max.nil? || number <= max)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# @api private
|
|
312
|
+
def regex(value, pattern)
|
|
313
|
+
expression = Regexp.new(pattern.to_s, timeout: 0.05)
|
|
314
|
+
result(expression.match?(value.to_s))
|
|
315
|
+
rescue Regexp::TimeoutError
|
|
316
|
+
result(false, code: "REGEX_TIMEOUT")
|
|
317
|
+
rescue RegexpError => e
|
|
318
|
+
raise Error, "regex: #{e.message}"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# @api private
|
|
322
|
+
def format_number(args)
|
|
323
|
+
number = Functions.number(args["value"]) or return ""
|
|
324
|
+
decimals = Functions.number(args["decimals"])
|
|
325
|
+
delimiter = args["grouping"] == false ? "" : ","
|
|
326
|
+
if decimals
|
|
327
|
+
ActiveSupport::NumberHelper.number_to_rounded(number, precision: decimals.to_i, delimiter: delimiter,
|
|
328
|
+
separator: ".")
|
|
329
|
+
else
|
|
330
|
+
number = number.to_i if number.is_a?(Float) && number == number.floor
|
|
331
|
+
ActiveSupport::NumberHelper.number_to_delimited(number, delimiter: delimiter, separator: ".")
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# @api private
|
|
336
|
+
def format_currency(args)
|
|
337
|
+
number = Functions.number(args["value"]) or return ""
|
|
338
|
+
code = args["currency"].to_s.upcase
|
|
339
|
+
unit, digits = CURRENCIES.fetch(code, ["#{code} ", 2])
|
|
340
|
+
decimals = Functions.number(args["decimals"])&.to_i || digits
|
|
341
|
+
ActiveSupport::NumberHelper.number_to_currency(number, unit: unit, precision: decimals, separator: ".",
|
|
342
|
+
delimiter: args["grouping"] == false ? "" : ",",
|
|
343
|
+
format: "%u%n", negative_format: "-%u%n")
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# @api private
|
|
347
|
+
def format_date(value, pattern)
|
|
348
|
+
time = parse_time(value) or return ""
|
|
349
|
+
time.strftime(strftime_pattern(pattern.to_s))
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# @api private
|
|
353
|
+
def parse_time(value)
|
|
354
|
+
case value
|
|
355
|
+
when Time then value
|
|
356
|
+
when Numeric then Time.at(value.abs >= 1e11 ? value / 1000.0 : value).utc
|
|
357
|
+
when String then parse_time_string(value.strip)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# ISO 8601 first (a date alone is midnight UTC), then anything
|
|
362
|
+
# Time.parse reads; nil when neither does.
|
|
363
|
+
# @api private
|
|
364
|
+
def parse_time_string(text)
|
|
365
|
+
return if text.empty?
|
|
366
|
+
return Time.utc(*text.split("-").map(&:to_i)) if text.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
367
|
+
|
|
368
|
+
Time.iso8601(text)
|
|
369
|
+
rescue ArgumentError
|
|
370
|
+
begin
|
|
371
|
+
Time.parse(text)
|
|
372
|
+
rescue ArgumentError
|
|
373
|
+
nil
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# @api private
|
|
378
|
+
def strftime_pattern(pattern)
|
|
379
|
+
pattern.gsub(/'((?:[^']|'')*)'|([A-Za-z])\2*|%/) do |token|
|
|
380
|
+
if token.start_with?("'") then token[1...-1].gsub("''", "'").gsub("%", "%%")
|
|
381
|
+
elsif token == "%" then "%%"
|
|
382
|
+
else DATE_FIELDS[token] || DATE_FIELDS[token[0] * [token.length, 4].min] || token
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# @api private
|
|
388
|
+
def pluralize(args)
|
|
389
|
+
number = Functions.number(args["value"])
|
|
390
|
+
category = plural_category(number)
|
|
391
|
+
form = args[category]
|
|
392
|
+
form = args["other"] if form.nil? || (category != "other" && !args.key?(category))
|
|
393
|
+
form.to_s
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# @api private
|
|
397
|
+
def plural_category(number)
|
|
398
|
+
return "other" if number.nil?
|
|
399
|
+
return "zero" if number.zero?
|
|
400
|
+
return "one" if number == 1
|
|
401
|
+
return "two" if number == 2
|
|
402
|
+
|
|
403
|
+
"other"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# @api private
|
|
407
|
+
def open_url(url)
|
|
408
|
+
text = url.to_s.strip
|
|
409
|
+
raise Error, "openUrl: only http and https URLs open" unless text.match?(%r{\Ahttps?://\S+\z})
|
|
410
|
+
|
|
411
|
+
text
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Poetry
|
|
6
|
+
module Agent
|
|
7
|
+
module A2UI
|
|
8
|
+
# The Markdown subset an A2UI Text component needs, rendered without
|
|
9
|
+
# a Markdown dependency: ATX headings, paragraphs, bullet lists,
|
|
10
|
+
# emphasis, strong, inline code, and links. Input is escaped first,
|
|
11
|
+
# so agent text never reaches the page as markup.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# Markdown.render("## Login\nWelcome **back**") # => "<h2>Login</h2><p>Welcome <strong>back</strong></p>"
|
|
15
|
+
module Markdown
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param text [String]
|
|
19
|
+
# @return [String] HTML (unmarked; wrap in `html_safe` at the render site)
|
|
20
|
+
def render(text)
|
|
21
|
+
blocks(text.to_s).map { |block| block_html(block) }.join
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Strips the same markers instead of rendering them - the
|
|
25
|
+
# fallback the basic catalog guide asks for when markup is unwanted.
|
|
26
|
+
#
|
|
27
|
+
# @param text [String]
|
|
28
|
+
# @return [String] plain text
|
|
29
|
+
def strip(text)
|
|
30
|
+
text.to_s.gsub(/^\#{1,6}\s+/, "").gsub(/\*\*(.+?)\*\*/, '\1').gsub(/(?<!\w)[*_](.+?)[*_](?!\w)/, '\1')
|
|
31
|
+
.gsub(/`([^`]+)`/, '\1').gsub(/\[([^\]]+)\]\([^)]+\)/, '\1').gsub(/^[-*]\s+/, "")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @api private
|
|
35
|
+
def blocks(text)
|
|
36
|
+
text.split(/\n{2,}/).map(&:strip).reject(&:empty?)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @api private
|
|
40
|
+
def block_html(block)
|
|
41
|
+
if (match = block.match(/\A(\#{1,6})\s+(.*)\z/m))
|
|
42
|
+
level = match[1].length
|
|
43
|
+
"<h#{level}>#{inline(match[2].strip)}</h#{level}>"
|
|
44
|
+
elsif block.lines.all? { |line| line.match?(/\A\s*[-*]\s+/) }
|
|
45
|
+
items = block.lines.map { |line| "<li>#{inline(line.sub(/\A\s*[-*]\s+/, "").strip)}</li>" }
|
|
46
|
+
"<ul>#{items.join}</ul>"
|
|
47
|
+
else
|
|
48
|
+
"<p>#{block.lines.map { |line| inline(line.strip) }.join("<br>")}</p>"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @api private
|
|
53
|
+
def inline(text)
|
|
54
|
+
html = ERB::Util.html_escape(text).to_str
|
|
55
|
+
html = html.gsub(/`([^`]+)`/, '<code>\1</code>')
|
|
56
|
+
html = html.gsub(/\*\*(.+?)\*\*/, '<strong>\1</strong>')
|
|
57
|
+
html = html.gsub(/(?<!\w)[*_](.+?)[*_](?!\w)/, '<em>\1</em>')
|
|
58
|
+
html.gsub(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}, '<a href="\2">\1</a>')
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Agent
|
|
5
|
+
module A2UI
|
|
6
|
+
# JSON Pointer (RFC 6901) over plain Ruby documents, with A2UI's two
|
|
7
|
+
# extensions: relative paths (no leading slash) resolve against a
|
|
8
|
+
# collection scope, and an upsert writes through missing objects.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# Pointer.get({ "user" => { "name" => "Ada" } }, "/user/name") # => "Ada"
|
|
12
|
+
# Pointer.absolute("name", "/users/1") # => "/users/1/name"
|
|
13
|
+
module Pointer
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Splits a pointer into unescaped reference tokens; `""` and `"/"`
|
|
17
|
+
# both name the whole document.
|
|
18
|
+
#
|
|
19
|
+
# @param path [String]
|
|
20
|
+
# @return [Array<String>]
|
|
21
|
+
def tokens(path)
|
|
22
|
+
path = path.to_s
|
|
23
|
+
return [] if path.empty? || path == "/"
|
|
24
|
+
|
|
25
|
+
path.delete_prefix("/").split("/", -1).map { |token| token.gsub("~1", "/").gsub("~0", "~") }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Joins tokens back into a pointer.
|
|
29
|
+
#
|
|
30
|
+
# @param parts [Array<String>]
|
|
31
|
+
# @return [String]
|
|
32
|
+
def build(parts)
|
|
33
|
+
return "/" if parts.empty?
|
|
34
|
+
|
|
35
|
+
"/#{parts.map { |part| part.to_s.gsub("~", "~0").gsub("/", "~1") }.join("/")}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Resolves a path against a scope: absolute paths pass through,
|
|
39
|
+
# relative ones append to the scope (the root when no scope).
|
|
40
|
+
#
|
|
41
|
+
# @param path [String]
|
|
42
|
+
# @param scope [String, nil] the collection-item pointer in effect
|
|
43
|
+
# @return [String] an absolute pointer
|
|
44
|
+
def absolute(path, scope = nil)
|
|
45
|
+
path = path.to_s
|
|
46
|
+
return path if path.start_with?("/")
|
|
47
|
+
return build(tokens(path)) if scope.nil? || scope.empty? || scope == "/"
|
|
48
|
+
|
|
49
|
+
build(tokens(scope) + tokens(path))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Reads the value at a pointer; nil for any missing step.
|
|
53
|
+
#
|
|
54
|
+
# @param document [Object]
|
|
55
|
+
# @param path [String]
|
|
56
|
+
# @return [Object, nil]
|
|
57
|
+
def get(document, path)
|
|
58
|
+
tokens(path).reduce(document) do |node, token|
|
|
59
|
+
case node
|
|
60
|
+
when Hash then node[token]
|
|
61
|
+
when Array then token.match?(/\A\d+\z/) ? node[token.to_i] : nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Writes a value at a pointer (A2UI upsert semantics): missing
|
|
67
|
+
# objects are created along the way, a nil value removes the key,
|
|
68
|
+
# and the whole-document pointer replaces the document.
|
|
69
|
+
#
|
|
70
|
+
# @param document [Hash, Array, nil]
|
|
71
|
+
# @param path [String]
|
|
72
|
+
# @param value [Object, nil]
|
|
73
|
+
# @return [Object] the updated document
|
|
74
|
+
def upsert(document, path, value)
|
|
75
|
+
parts = tokens(path)
|
|
76
|
+
return value if parts.empty?
|
|
77
|
+
|
|
78
|
+
document = {} unless document.is_a?(Hash) || document.is_a?(Array)
|
|
79
|
+
node = document
|
|
80
|
+
parts[0...-1].each do |token|
|
|
81
|
+
child = child_of(node, token)
|
|
82
|
+
unless child.is_a?(Hash) || child.is_a?(Array)
|
|
83
|
+
child = {}
|
|
84
|
+
store(node, token, child)
|
|
85
|
+
end
|
|
86
|
+
node = child
|
|
87
|
+
end
|
|
88
|
+
value.nil? ? delete(node, parts.last) : store(node, parts.last, value)
|
|
89
|
+
document
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @api private
|
|
93
|
+
def child_of(node, token)
|
|
94
|
+
node.is_a?(Array) ? node[token.to_i] : node[token]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @api private
|
|
98
|
+
def store(node, token, value)
|
|
99
|
+
if node.is_a?(Array)
|
|
100
|
+
token == "-" ? node.push(value) : node[token.to_i] = value
|
|
101
|
+
else
|
|
102
|
+
node[token] = value
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @api private
|
|
107
|
+
def delete(node, token)
|
|
108
|
+
node.is_a?(Array) ? node.delete_at(token.to_i) : node.delete(token)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Agent
|
|
5
|
+
module A2UI
|
|
6
|
+
# The protocol version the projection targets.
|
|
7
|
+
PROTOCOL_VERSION = "1.0"
|
|
8
|
+
# The shared type definitions a catalog may reference.
|
|
9
|
+
COMMON_TYPES = "https://a2ui.org/specification/v1_0/common_types.json#/$defs/"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|