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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +51 -0
  5. data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
  6. data/app/javascript/poetry/agent/adapter.js +77 -0
  7. data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
  8. data/app/javascript/poetry/agent/index.js +41 -0
  9. data/app/javascript/poetry/agent/stream_actions.js +65 -0
  10. data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
  11. data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
  12. data/config/controllers_manifest.json +82 -0
  13. data/config/importmap.rb +10 -0
  14. data/exe/poetry-agent +28 -0
  15. data/lib/poetry/agent/a2ui/catalog.rb +289 -0
  16. data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
  17. data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
  18. data/lib/poetry/agent/a2ui/checks.rb +45 -0
  19. data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
  20. data/lib/poetry/agent/a2ui/expression.rb +175 -0
  21. data/lib/poetry/agent/a2ui/functions.rb +417 -0
  22. data/lib/poetry/agent/a2ui/markdown.rb +63 -0
  23. data/lib/poetry/agent/a2ui/pointer.rb +113 -0
  24. data/lib/poetry/agent/a2ui/protocol.rb +12 -0
  25. data/lib/poetry/agent/a2ui/renderer.rb +242 -0
  26. data/lib/poetry/agent/a2ui/session.rb +302 -0
  27. data/lib/poetry/agent/a2ui/streams.rb +82 -0
  28. data/lib/poetry/agent/a2ui/surface.rb +352 -0
  29. data/lib/poetry/agent/a2ui.rb +48 -0
  30. data/lib/poetry/agent/agui/client.rb +69 -0
  31. data/lib/poetry/agent/agui/json_patch.rb +137 -0
  32. data/lib/poetry/agent/agui/relay.rb +105 -0
  33. data/lib/poetry/agent/agui/run_input.rb +83 -0
  34. data/lib/poetry/agent/agui/sse.rb +97 -0
  35. data/lib/poetry/agent/agui/transcript.rb +540 -0
  36. data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
  37. data/lib/poetry/agent/agui.rb +87 -0
  38. data/lib/poetry/agent/config.rb +49 -0
  39. data/lib/poetry/agent/engine.rb +37 -0
  40. data/lib/poetry/agent/mcp/bundled.rb +54 -0
  41. data/lib/poetry/agent/mcp/http.rb +89 -0
  42. data/lib/poetry/agent/mcp/server.rb +962 -0
  43. data/lib/poetry/agent/version.rb +8 -0
  44. data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
  45. data/lib/poetry/agent/webmcp.rb +37 -0
  46. data/lib/poetry/agent.rb +66 -0
  47. data/lib/poetry-agent.rb +4 -0
  48. 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