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,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pointer"
4
+ require_relative "evaluator"
5
+ require_relative "checks"
6
+
7
+ module Poetry
8
+ module Agent
9
+ module A2UI
10
+ # One A2UI surface on the renderer side: its flat component list
11
+ # (an adjacency list keyed by id, `root` at the top), its data
12
+ # model, and a monotonic version the Turbo Stream delivery compares.
13
+ # A surface belongs to a catalog binding, which knows how each
14
+ # component references its children; the surface itself is
15
+ # catalog-agnostic beyond that.
16
+ #
17
+ # @example
18
+ # surface = Surface.new(id: "card", catalog: Catalogs::Basic.new)
19
+ # surface.update_components([{ "id" => "root", "component" => "Text", "text" => { "path" => "/name" } }])
20
+ # surface.update_data("/name", "Ada")
21
+ # surface.resolve({ "path" => "/name" }) # => "Ada"
22
+ class Surface
23
+ # Component names are UAX #31 identifiers.
24
+ NAME_PATTERN = /\A[\p{XID_Start}_]\p{XID_Continue}*\z/u
25
+ # The reserved container the renderer instantiates on createSurface.
26
+ RESERVED_COMPONENT = "Surface"
27
+
28
+ # @return [String]
29
+ attr_reader :id
30
+ # @return [String, nil] the catalog id the agent named
31
+ attr_reader :catalog_id
32
+ # @return [Object] the catalog binding (see {Catalogs::Basic})
33
+ attr_reader :catalog
34
+ # @return [Boolean] whether actions carry the whole data model
35
+ attr_reader :send_data_model
36
+ # @return [Hash{String => Hash}] components by id
37
+ attr_reader :components
38
+ # @return [Hash] the data model
39
+ attr_reader :data
40
+ # @return [Integer] bumps on every applied change
41
+ attr_reader :version
42
+
43
+ # @param id [String]
44
+ # @param catalog [Object] the catalog binding
45
+ # @param catalog_id [String, nil]
46
+ # @param send_data_model [Boolean]
47
+ # @param data [Hash, nil] the initial data model
48
+ # @param components [Array<Hash>] the initial component list
49
+ def initialize(id:, catalog:, catalog_id: nil, send_data_model: false, data: nil, components: [])
50
+ @id = id
51
+ @catalog = catalog
52
+ @catalog_id = catalog_id
53
+ @send_data_model = send_data_model ? true : false
54
+ @data = deep_copy(data.is_a?(Hash) ? data : {})
55
+ @components = {}
56
+ @version = 0
57
+ @errors = []
58
+ update_components(components) if components.any?
59
+ end
60
+
61
+ # Upserts components by id and validates the result. Returns the
62
+ # validation errors (each `{ code:, path:, message: }`); a dangling
63
+ # child reference is not one - streaming delivers children later.
64
+ #
65
+ # @param list [Array<Hash>]
66
+ # @return [Array<Hash>]
67
+ def update_components(list)
68
+ errors = []
69
+ Array(list).each_with_index do |component, index|
70
+ error = component_error(component, index)
71
+ errors << error and next if error
72
+
73
+ @components[component["id"]] = deep_copy(component)
74
+ end
75
+ errors.concat(cycle_errors)
76
+ bump!
77
+ errors
78
+ end
79
+
80
+ # Applies an `updateDataModel` (upsert; nil removes; the root
81
+ # pointer replaces the whole model).
82
+ #
83
+ # @param path [String, nil]
84
+ # @param value [Object, nil]
85
+ # @return [void]
86
+ def update_data(path, value)
87
+ @data = Pointer.upsert(@data, path || "/", deep_copy(value))
88
+ @data = {} unless @data.is_a?(Hash)
89
+ bump!
90
+ end
91
+
92
+ # @return [Hash, nil] the top-level component
93
+ def root
94
+ @components["root"]
95
+ end
96
+
97
+ # @param component_id [String]
98
+ # @return [Hash, nil]
99
+ def component(component_id)
100
+ @components[component_id]
101
+ end
102
+
103
+ # Resolves a dynamic value in a scope: a `{ "path" => ... }` binding
104
+ # reads the data model (relative paths against the scope), a
105
+ # `{ "call" => ... }` function call runs through the catalog's
106
+ # functions (see {Evaluator}), anything else is a literal.
107
+ #
108
+ # @param value [Object]
109
+ # @param scope [String, nil] the collection-item pointer in effect
110
+ # @param on_error [#call, nil] receives each function problem's message
111
+ # @return [Object, nil]
112
+ def resolve(value, scope = nil, on_error: nil)
113
+ Evaluator.new(self, scope, on_error: on_error).resolve(value)
114
+ end
115
+
116
+ # The string a resolved value displays as (the spec's conversion
117
+ # rules: nil is empty, containers are JSON).
118
+ #
119
+ # @param value [Object]
120
+ # @param scope [String, nil]
121
+ # @param on_error [#call, nil] receives each function problem's message
122
+ # @return [String]
123
+ def text(value, scope = nil, on_error: nil)
124
+ evaluator = Evaluator.new(self, scope, on_error: on_error)
125
+ evaluator.stringify(evaluator.resolve(value))
126
+ end
127
+
128
+ # Evaluates every rendered component's `checks` against the data
129
+ # model, keyed the way an action names its source (`id`, or
130
+ # `id@scope` inside a template).
131
+ #
132
+ # @param on_error [#call, nil] receives each function problem's message
133
+ # @return [Hash{String => Array<Hash>}] failures by component key
134
+ def failures(on_error: nil)
135
+ result = {}
136
+ walk do |component, scope|
137
+ next unless component["checks"].is_a?(Array)
138
+
139
+ found = Checks.failures(component, Evaluator.new(self, scope, on_error: on_error))
140
+ result[source_key(component, scope)] = found if found.any?
141
+ end
142
+ result
143
+ end
144
+
145
+ # @param component [Hash]
146
+ # @param scope [String, nil]
147
+ # @return [String] `id`, or `id@scope` inside a template
148
+ def source_key(component, scope = nil)
149
+ scope ? "#{component["id"]}@#{scope}" : component["id"].to_s
150
+ end
151
+
152
+ # What a client-side evaluator needs to run the checks as the user
153
+ # types: every checked component's rules with its bindings made
154
+ # absolute for its scope, the bound inputs by absolute path with
155
+ # their kinds, and the data model for paths no input carries.
156
+ #
157
+ # @return [Hash] `{ "checks" => { key => { "kind", "rules" } }, "inputs" => { path => kind }, "model" => data }`
158
+ def program
159
+ checks = {}
160
+ walk do |component, scope|
161
+ rules = Array(component["checks"]).grep(Hash).select { |rule| rule["condition"] }
162
+ next if rules.empty?
163
+
164
+ checks[source_key(component, scope)] = {
165
+ "kind" => component["action"].is_a?(Hash) ? "button" : "input",
166
+ "rules" => rules.map do |rule|
167
+ { "condition" => absolutize(rule["condition"], scope), "message" => rule["message"] }
168
+ end
169
+ }
170
+ end
171
+ { "checks" => checks, "inputs" => inputs.to_h { |input| [input[:path], input[:kind].to_s] }, "model" => data }
172
+ end
173
+
174
+ # @param value [Object]
175
+ # @return [Boolean] whether the value is a `{ "path" => ... }` data binding
176
+ def binding?(value)
177
+ value.is_a?(Hash) && value.key?("path") && !value.key?("componentId")
178
+ end
179
+
180
+ # @param value [Object]
181
+ # @return [Boolean] whether the value is a `{ "call" => ... }` function call
182
+ def function_call?(value)
183
+ value.is_a?(Hash) && value.key?("call")
184
+ end
185
+
186
+ # @param value [Object]
187
+ # @return [Boolean] whether the value is a `{ "componentId", "path" }` template
188
+ def template?(value)
189
+ value.is_a?(Hash) && value.key?("componentId")
190
+ end
191
+
192
+ # Reads a bound path in a scope.
193
+ #
194
+ # @param path [String]
195
+ # @param scope [String, nil]
196
+ # @return [Object, nil]
197
+ def read(path, scope = nil)
198
+ Pointer.get(@data, Pointer.absolute(path, scope))
199
+ end
200
+
201
+ # Expands a child reference into `[id, scope]` pairs: an id array
202
+ # keeps the scope, a template instantiates its component once per
203
+ # item of the bound array with the item's pointer as the scope.
204
+ #
205
+ # @param reference [Array<String>, Hash, String, nil]
206
+ # @param scope [String, nil]
207
+ # @return [Array<Array(String, String)>]
208
+ def expand(reference, scope = nil)
209
+ case reference
210
+ when String then [[reference, scope]]
211
+ when Array then reference.grep(String).map { |child| [child, scope] }
212
+ when Hash
213
+ return [] unless template?(reference)
214
+
215
+ path = Pointer.absolute(reference["path"].to_s, scope)
216
+ items = Pointer.get(@data, path)
217
+ return [] unless items.is_a?(Array)
218
+
219
+ items.each_index.map { |index| [reference["componentId"], "#{path}/#{index}"] }
220
+ else []
221
+ end
222
+ end
223
+
224
+ # Walks the rendered tree depth-first from the root, yielding each
225
+ # `[component, scope]` in render order (templates instantiate once
226
+ # per item; a cycle guard keeps the walk finite).
227
+ #
228
+ # @yieldparam component [Hash]
229
+ # @yieldparam scope [String, nil]
230
+ # @return [void]
231
+ def walk(&)
232
+ walk_from("root", nil, [], &)
233
+ end
234
+
235
+ # Bound input descriptors of the rendered tree, absolute paths only.
236
+ #
237
+ # @return [Array<Hash>] `{ path:, kind: }` (kind: :string, :boolean, :number, :string_list)
238
+ def inputs
239
+ result = []
240
+ walk do |component, scope|
241
+ result.concat(Array(catalog.inputs(component, scope)))
242
+ end
243
+ result.uniq { |input| input[:path] }
244
+ end
245
+
246
+ # @return [Hash] a JSON-ready snapshot
247
+ def to_h
248
+ { "surfaceId" => id, "catalogId" => catalog_id, "sendDataModel" => send_data_model,
249
+ "version" => version, "components" => components.values, "dataModel" => data }
250
+ end
251
+
252
+ private
253
+
254
+ def bump!
255
+ @version += 1
256
+ end
257
+
258
+ # Rewrites every binding in a value to its absolute pointer.
259
+ def absolutize(value, scope)
260
+ case value
261
+ when Array then value.map { |item| absolutize(item, scope) }
262
+ when Hash
263
+ return { "path" => Pointer.absolute(value["path"].to_s, scope) } if binding?(value)
264
+
265
+ value.transform_values { |item| absolutize(item, scope) }
266
+ else value
267
+ end
268
+ end
269
+
270
+ def walk_from(component_id, scope, stack, &)
271
+ key = [component_id, scope]
272
+ return if stack.include?(key)
273
+
274
+ component = @components[component_id]
275
+ return unless component
276
+
277
+ yield component, scope
278
+ Array(catalog.references(component)).each do |reference|
279
+ expand(reference, scope).each do |child_id, child_scope|
280
+ walk_from(child_id, child_scope, stack + [key], &)
281
+ end
282
+ end
283
+ end
284
+
285
+ def component_error(component, index)
286
+ path = "/components/#{index}"
287
+ return error(path, "component must be an object") unless component.is_a?(Hash)
288
+
289
+ id = component["id"]
290
+ name = component["component"]
291
+ return error(path, "id (string) is required") unless id.is_a?(String) && !id.empty?
292
+ return error("#{path}/component", "component (string) is required") unless name.is_a?(String)
293
+ return error("#{path}/component", "#{name.inspect} is not an identifier") unless name.match?(NAME_PATTERN)
294
+ return error("#{path}/component", "#{RESERVED_COMPONENT} is reserved") if name == RESERVED_COMPONENT
295
+
296
+ nil
297
+ end
298
+
299
+ # Static edges (template components count) coloured depth-first.
300
+ def cycle_errors
301
+ state = {}
302
+ errors = []
303
+ @components.each_key do |component_id|
304
+ visit(component_id, state, [], errors)
305
+ end
306
+ errors
307
+ end
308
+
309
+ def visit(component_id, state, stack, errors)
310
+ return if state[component_id] == :done
311
+
312
+ if state[component_id] == :open
313
+ errors << error("/components/#{component_id}",
314
+ "circular reference: #{(stack + [component_id]).join(" -> ")}")
315
+ return
316
+ end
317
+
318
+ state[component_id] = :open
319
+ static_children(component_id).each { |child| visit(child, state, stack + [component_id], errors) }
320
+ state[component_id] = :done
321
+ end
322
+
323
+ def static_children(component_id)
324
+ component = @components[component_id]
325
+ return [] unless component
326
+
327
+ children = Array(catalog.references(component)).flat_map do |reference|
328
+ case reference
329
+ when String then [reference]
330
+ when Array then reference.grep(String)
331
+ when Hash then template?(reference) ? [reference["componentId"]] : []
332
+ else []
333
+ end
334
+ end
335
+ children.select { |child| @components.key?(child) }
336
+ end
337
+
338
+ def error(path, message)
339
+ { code: "VALIDATION_FAILED", path: path, message: message }
340
+ end
341
+
342
+ def deep_copy(value)
343
+ case value
344
+ when Hash then value.to_h { |key, item| [key.to_s, deep_copy(item)] }
345
+ when Array then value.map { |item| deep_copy(item) }
346
+ else value
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "a2ui/protocol"
4
+ require_relative "a2ui/catalog"
5
+ require_relative "a2ui/pointer"
6
+ require_relative "a2ui/markdown"
7
+ require_relative "a2ui/expression"
8
+ require_relative "a2ui/functions"
9
+ require_relative "a2ui/evaluator"
10
+ require_relative "a2ui/checks"
11
+ require_relative "a2ui/surface"
12
+ require_relative "a2ui/session"
13
+ require_relative "a2ui/renderer"
14
+ require_relative "a2ui/catalogs/basic"
15
+ require_relative "a2ui/catalogs/native"
16
+ require_relative "a2ui/streams"
17
+
18
+ module Poetry
19
+ module Agent
20
+ # The A2UI surface: Google's declarative generative-UI format, where
21
+ # an agent emits a flat component list against a client-owned catalog
22
+ # and the client renders it with its own components. Two halves ship:
23
+ #
24
+ # - {Catalog} projects Poetry's registry into an A2UI v1.0 catalog
25
+ # document, so any A2UI agent generates against Poetry's vocabulary
26
+ # and a renderer validates what arrives against the same document.
27
+ # - The renderer: {Session} folds the envelope (`createSurface`,
28
+ # `updateComponents`, `updateDataModel`, `deleteSurface`) into
29
+ # {Surface}s, {Renderer} renders a surface through the host's view
30
+ # context with a catalog binding ({Catalogs::Basic} for the spec's
31
+ # basic catalog, {Catalogs::Native} for Poetry's own), {Streams}
32
+ # delivers changes as versioned Turbo Streams, and a submitted
33
+ # surface form becomes the spec's `action` message
34
+ # ({Session#action}). {Functions} holds the basic catalog's function
35
+ # set - the `formatString` grammar ({Expression}), the formatters,
36
+ # the validators behind `checks` ({Checks}) - which {Evaluator} runs
37
+ # for both catalogs.
38
+ module A2UI
39
+ # The catalog bindings a {Session} starts with: the spec's basic
40
+ # catalog and Poetry's own, keyed by catalog id.
41
+ #
42
+ # @return [Hash{String => Object}]
43
+ def self.catalogs
44
+ { Catalogs::Basic::ID => Catalogs::Basic.new, Catalog::DEFAULT_ID => Catalogs::Native.new }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Poetry
8
+ module Agent
9
+ module AGUI
10
+ # The HTTP client: POSTs a `RunAgentInput` to an AG-UI endpoint and
11
+ # yields the streamed events as they arrive (stdlib Net::HTTP,
12
+ # `text/event-stream`). One call is one run; the multi-run model
13
+ # (client tools, interrupts) is the caller's loop over {Transcript}.
14
+ class Client
15
+ # Raised for a non-success HTTP status.
16
+ class Error < Poetry::Core::Error
17
+ # @return [Integer]
18
+ attr_reader :status
19
+
20
+ def initialize(message, status:)
21
+ super(message)
22
+ @status = status
23
+ end
24
+ end
25
+
26
+ # @param url [String] the agent's run endpoint
27
+ # @param headers [Hash{String => String}] extra request headers (auth)
28
+ # @param open_timeout [Numeric] seconds
29
+ # @param read_timeout [Numeric] seconds between chunks
30
+ def initialize(url:, headers: {}, open_timeout: 10, read_timeout: 120)
31
+ @uri = URI(url)
32
+ @headers = headers
33
+ @open_timeout = open_timeout
34
+ @read_timeout = read_timeout
35
+ end
36
+
37
+ # Runs the agent and yields every event.
38
+ #
39
+ # @param input [Hash] the wire hash ({RunInput.build})
40
+ # @yieldparam event [Hash]
41
+ # @return [SSE::Parser] the parser (its `errors` list any unreadable lines)
42
+ # @raise [Error] on a non-2xx response
43
+ # @example
44
+ # client.run(input) { |event| transcript.apply(event) }
45
+ def run(input, &)
46
+ request = Net::HTTP::Post.new(@uri)
47
+ request["Content-Type"] = "application/json"
48
+ request["Accept"] = "text/event-stream"
49
+ @headers.each { |name, value| request[name] = value }
50
+ request.body = JSON.generate(input)
51
+
52
+ parser = SSE::Parser.new
53
+ Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https",
54
+ open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
55
+ http.request(request) do |response|
56
+ unless response.is_a?(Net::HTTPSuccess)
57
+ raise Error.new("AG-UI endpoint answered #{response.code}", status: response.code.to_i)
58
+ end
59
+
60
+ response.read_body { |chunk| parser.feed(chunk, &) }
61
+ end
62
+ end
63
+ parser.finish(&)
64
+ parser
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Agent
5
+ module AGUI
6
+ # RFC 6902 JSON Patch over plain Ruby data (Hash / Array), with RFC
7
+ # 6901 JSON Pointer paths - what AG-UI's STATE_DELTA and
8
+ # ACTIVITY_DELTA carry. Applies atomically: the document is deep-
9
+ # copied first and the copy is returned, so a failing operation
10
+ # leaves the caller's document untouched.
11
+ module JsonPatch
12
+ # Raised for an operation the document cannot take (an unknown
13
+ # op, a missing path, a failed test).
14
+ class Error < Poetry::Core::Error; end
15
+
16
+ module_function
17
+
18
+ # Applies a patch and returns the patched copy.
19
+ #
20
+ # @param document [Hash, Array]
21
+ # @param operations [Array<Hash>] RFC 6902 operations (string or symbol keys)
22
+ # @return [Hash, Array] the new document
23
+ # @raise [Error] on an invalid operation
24
+ # @example
25
+ # JsonPatch.apply({ "a" => 1 }, [{ "op" => "replace", "path" => "/a", "value" => 2 }])
26
+ # # => { "a" => 2 }
27
+ def apply(document, operations)
28
+ result = deep_copy(document)
29
+ Array(operations).each do |operation|
30
+ op = AGUI.field(operation, "op").to_s
31
+ path = AGUI.field(operation, "path").to_s
32
+ case op
33
+ when "add" then result = add(result, path, deep_copy(AGUI.field(operation, "value")))
34
+ when "remove" then result = remove(result, path)
35
+ when "replace"
36
+ value = deep_copy(AGUI.field(operation, "value"))
37
+ result = path.empty? ? value : add(remove(result, path), path, value)
38
+ when "move"
39
+ from = AGUI.field(operation, "from").to_s
40
+ value = get(result, from)
41
+ result = remove(result, from)
42
+ result = add(result, path, value)
43
+ when "copy" then result = add(result, path, deep_copy(get(result, AGUI.field(operation, "from").to_s)))
44
+ when "test"
45
+ raise Error, "test failed at #{path}" unless get(result, path) == AGUI.field(operation, "value")
46
+ else raise Error, "unknown op #{op.inspect}"
47
+ end
48
+ end
49
+ result
50
+ end
51
+
52
+ # Reads the value at a JSON Pointer.
53
+ #
54
+ # @param document [Hash, Array]
55
+ # @param path [String] RFC 6901 pointer ("" is the whole document)
56
+ # @return [Object]
57
+ # @raise [Error] when the path does not resolve
58
+ def get(document, path)
59
+ tokens(path).reduce(document) do |node, token|
60
+ case node
61
+ when Hash then node.key?(token) ? node[token] : raise(Error, "no such path #{path}")
62
+ when Array then node[index_of(node, token, path)] || raise(Error, "no such path #{path}")
63
+ else raise Error, "no such path #{path}"
64
+ end
65
+ end
66
+ end
67
+
68
+ # @api private
69
+ def add(document, path, value)
70
+ parts = tokens(path)
71
+ return value if parts.empty?
72
+
73
+ parent = get(document, pointer(parts[0...-1]))
74
+ last = parts.last
75
+ case parent
76
+ when Hash then parent[last] = value
77
+ when Array
78
+ if last == "-" then parent << value
79
+ else
80
+ index = Integer(last, exception: false) || raise(Error, "bad index #{last} at #{path}")
81
+ raise Error, "index out of range at #{path}" if index > parent.length || index.negative?
82
+
83
+ parent.insert(index, value)
84
+ end
85
+ else raise Error, "cannot add into #{parent.class} at #{path}"
86
+ end
87
+ document
88
+ end
89
+
90
+ # @api private
91
+ def remove(document, path)
92
+ parts = tokens(path)
93
+ raise Error, "cannot remove the whole document" if parts.empty?
94
+
95
+ parent = get(document, pointer(parts[0...-1]))
96
+ last = parts.last
97
+ case parent
98
+ when Hash then parent.key?(last) ? parent.delete(last) : raise(Error, "no such path #{path}")
99
+ when Array then parent.delete_at(index_of(parent, last, path))
100
+ else raise Error, "cannot remove from #{parent.class} at #{path}"
101
+ end
102
+ document
103
+ end
104
+
105
+ # @api private
106
+ def tokens(path)
107
+ return [] if path.nil? || path.empty?
108
+ raise Error, "pointer must start with / (#{path})" unless path.start_with?("/")
109
+
110
+ path[1..].split("/", -1).map { |token| token.gsub("~1", "/").gsub("~0", "~") }
111
+ end
112
+
113
+ # @api private
114
+ def pointer(parts)
115
+ parts.map { |token| "/#{token.gsub("~", "~0").gsub("/", "~1")}" }.join
116
+ end
117
+
118
+ # @api private
119
+ def index_of(array, token, path)
120
+ index = Integer(token, exception: false)
121
+ raise Error, "bad index #{token} at #{path}" if index.nil? || index.negative? || index >= array.length
122
+
123
+ index
124
+ end
125
+
126
+ # @api private
127
+ def deep_copy(value)
128
+ case value
129
+ when Hash then value.to_h { |key, inner| [key, deep_copy(inner)] }
130
+ when Array then value.map { |inner| deep_copy(inner) }
131
+ else value
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end