pinecall 0.0.1

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +167 -0
  3. data/LICENSE +202 -0
  4. data/README.md +140 -0
  5. data/lib/pinecall/agent/author.rb +31 -0
  6. data/lib/pinecall/agent/config.rb +124 -0
  7. data/lib/pinecall/agent/doc.rb +73 -0
  8. data/lib/pinecall/agent/knowledge.rb +18 -0
  9. data/lib/pinecall/agent/searching.rb +33 -0
  10. data/lib/pinecall/agent/spec.rb +133 -0
  11. data/lib/pinecall/agent/state.rb +185 -0
  12. data/lib/pinecall/agent/tools.rb +119 -0
  13. data/lib/pinecall/agent.rb +160 -0
  14. data/lib/pinecall/blocks.rb +152 -0
  15. data/lib/pinecall/bridge.rb +239 -0
  16. data/lib/pinecall/call_world/answers.rb +61 -0
  17. data/lib/pinecall/call_world/room.rb +25 -0
  18. data/lib/pinecall/call_world.rb +175 -0
  19. data/lib/pinecall/client/agent.rb +179 -0
  20. data/lib/pinecall/client/call.rb +140 -0
  21. data/lib/pinecall/client/connection.rb +190 -0
  22. data/lib/pinecall/client/endpoints.rb +30 -0
  23. data/lib/pinecall/client/listeners.rb +42 -0
  24. data/lib/pinecall/client/observe.rb +109 -0
  25. data/lib/pinecall/client/rest.rb +61 -0
  26. data/lib/pinecall/client.rb +212 -0
  27. data/lib/pinecall/errors.rb +53 -0
  28. data/lib/pinecall/panel.rb +138 -0
  29. data/lib/pinecall/reading.rb +37 -0
  30. data/lib/pinecall/rules.rb +38 -0
  31. data/lib/pinecall/serve/held.rb +40 -0
  32. data/lib/pinecall/serve/loading.rb +102 -0
  33. data/lib/pinecall/serve/viewing.rb +40 -0
  34. data/lib/pinecall/serve.rb +169 -0
  35. data/lib/pinecall/testing.rb +190 -0
  36. data/lib/pinecall/version.rb +6 -0
  37. data/lib/pinecall/view.rb +70 -0
  38. data/lib/pinecall/wire/codec.rb +75 -0
  39. data/lib/pinecall/wire/enums.rb +104 -0
  40. data/lib/pinecall/wire/errors.rb +12 -0
  41. data/lib/pinecall/wire/reduce.rb +249 -0
  42. data/lib/pinecall/wire/registry.rb +168 -0
  43. data/lib/pinecall/wire/shapes.rb +33 -0
  44. data/lib/pinecall/wire/shapes_call_events.rb +137 -0
  45. data/lib/pinecall/wire/shapes_commands.rb +114 -0
  46. data/lib/pinecall/wire/shapes_config.rb +70 -0
  47. data/lib/pinecall/wire/shapes_doors.rb +96 -0
  48. data/lib/pinecall/wire/shapes_events.rb +358 -0
  49. data/lib/pinecall/wire/shapes_metrics.rb +109 -0
  50. data/lib/pinecall/wire/shapes_parts.rb +340 -0
  51. data/lib/pinecall/wire/state.rb +56 -0
  52. data/lib/pinecall/wire/validate.rb +154 -0
  53. data/lib/pinecall/wire.rb +43 -0
  54. data/lib/pinecall.rb +45 -0
  55. data/sig/pinecall.rbs +453 -0
  56. metadata +119 -0
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ class Client
5
+ # Gateway URLs built from the configured base URL.
6
+ module Endpoints
7
+ module_function
8
+
9
+ # `WS /v1/apps`; an http(s) base is converted to ws(s).
10
+ def apps(base) = door(base, "/v1/apps", websocket: true)
11
+
12
+ # `GET /v1/calls/{id}/events`: a call's log, as JSON or SSE.
13
+ def call_log(base, call) = door(base, "/v1/calls/#{CGI.escape(call)}/events")
14
+
15
+ # `GET /v1/agents/{slug}/calls`: the agent's log (registrations, configs, errors).
16
+ def agent_log(base, agent) = door(base, "/v1/agents/#{CGI.escape(agent)}/calls")
17
+
18
+ # `POST /v1/calls/{id}/lookup`: a platform tool run for one call this app serves.
19
+ def lookup(base, call) = door(base, "/v1/calls/#{CGI.escape(call)}/lookup")
20
+
21
+ def door(base, path, websocket: false)
22
+ url = URI.parse(base)
23
+ secure = %w[https wss].include?(url.scheme)
24
+ url.scheme = websocket ? (secure ? "wss" : "ws") : (secure ? "https" : "http")
25
+ url.path = "#{url.path.chomp("/")}#{path}"
26
+ url.to_s
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ class Client
5
+ # Event listener registry, used per agent and per call. A raising listener is reported to
6
+ # `on_error` and the remaining listeners still run.
7
+ class Listeners
8
+ def initialize(&on_error)
9
+ @by_type = Hash.new { |kept, type| kept[type] = [] }
10
+ @any = []
11
+ @on_error = on_error
12
+ @lock = Mutex.new
13
+ end
14
+
15
+ # Listen for one event type; the returned lambda unsubscribes.
16
+ def on(type, &listener)
17
+ @lock.synchronize { @by_type[type.to_s] << listener }
18
+ -> { @lock.synchronize { @by_type[type.to_s].delete(listener) } }
19
+ end
20
+
21
+ def on_any(&listener)
22
+ @lock.synchronize { @any << listener }
23
+ -> { @lock.synchronize { @any.delete(listener) } }
24
+ end
25
+
26
+ # Notify type listeners, then catch-all listeners.
27
+ def emit(event, context)
28
+ for_type, for_any = @lock.synchronize { [@by_type[event.type].dup, @any.dup] }
29
+ for_type.each { |listener| run { listener.call(event.data, context) } }
30
+ for_any.each { |listener| run { listener.call(event, context) } }
31
+ end
32
+
33
+ private
34
+
35
+ def run
36
+ yield
37
+ rescue StandardError => e
38
+ @on_error.call(e)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ class Client
5
+ # Reads a log as a JSON page or an SSE stream, reduced by the wire's reducer.
6
+ #
7
+ # Reconnects resume with `Last-Event-ID`. A reader that fell behind gets `log.gap` (with a
8
+ # snapshot when available), then `log.caught_up` once the stream is live.
9
+ module Observe
10
+ # One page of entries after the cursor, their reduced state, and the next cursor.
11
+ Page = Data.define(:entries, :state, :live, :next)
12
+
13
+ # One entry, its decoded event, and the reduced state including it.
14
+ Observation = Data.define(:entry, :event, :state)
15
+
16
+ RETRY_S = 1.0
17
+ LONGEST_RETRY_S = 30.0
18
+
19
+ module_function
20
+
21
+ # Fetch one page after `after`. Use the page's `next`, not the last entry's seq: an empty
22
+ # page of a live log still has one.
23
+ def history(target, url:, api_key:, after: 0)
24
+ answer = get(target, url:, api_key:, after:, accept: "application/json")
25
+ page = JSON.parse(answer.body, symbolize_names: true)
26
+ raise Error, "the log page is not { entries, live, next }" unless page.is_a?(Hash) && page[:entries].is_a?(Array)
27
+
28
+ entries = page[:entries].map { |raw| Wire.decode_entry(raw) }
29
+ Page.new(entries:, state: Wire.reduce(entries), live: page[:live] == true,
30
+ next: page[:next].is_a?(Integer) ? page[:next] : nil)
31
+ end
32
+
33
+ # Stream entries after `after`, reconnecting as needed. Yields an Observation per entry;
34
+ # returns when the block breaks or `stop` returns true.
35
+ def observe(target, url:, api_key:, after: 0, stop: nil)
36
+ state = Wire.initial_state
37
+ attempt = 0
38
+ opened = false
39
+ until stop&.call
40
+ begin
41
+ stream(target, url:, api_key:, after:) do |entry|
42
+ attempt = 0
43
+ opened = true
44
+ after = entry.seq
45
+ state = Wire.apply(state, entry)
46
+ yield Observation.new(entry:, event: Wire.event_of(entry), state:)
47
+ end
48
+ rescue StandardError
49
+ # Failing before the first entry means misconfiguration: raise. Later drops resume.
50
+ raise unless opened
51
+ end
52
+ attempt += 1
53
+ sleep([LONGEST_RETRY_S, RETRY_S * (2**(attempt - 1))].min * rand)
54
+ end
55
+ end
56
+
57
+ def stream(target, url:, api_key:, after:)
58
+ get(target, url:, api_key:, after:, accept: "text/event-stream") do |answer|
59
+ buffered = +""
60
+ answer.read_body do |chunk|
61
+ buffered << chunk
62
+ while (cut = buffered.index("\n\n"))
63
+ block = buffered.slice!(0, cut + 2)
64
+ entry = entry_in(block)
65
+ yield entry unless entry.nil?
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ # Parse one SSE block; nil for comments and keep-alives.
72
+ def entry_in(block)
73
+ data = block.lines.filter_map { |line| line.delete_prefix("data:").lstrip.chomp if line.start_with?("data:") }
74
+ return nil if data.empty?
75
+
76
+ Wire.decode_entry(JSON.parse(data.join("\n"), symbolize_names: true))
77
+ end
78
+
79
+ def get(target, url:, api_key:, after:, accept:, &block)
80
+ where = URI.parse(door(target, url))
81
+ where.query = URI.encode_www_form(after:) if after.positive?
82
+ request = Net::HTTP::Get.new(where)
83
+ request["accept"] = accept
84
+ request["authorization"] = "Bearer #{api_key}"
85
+ request["last-event-id"] = after.to_s if after.positive?
86
+ answer(where, request, &block)
87
+ end
88
+
89
+ def answer(where, request)
90
+ http = Net::HTTP.new(where.host, where.port)
91
+ http.use_ssl = where.scheme == "https"
92
+ http.read_timeout = nil
93
+ http.start do |open|
94
+ open.request(request) do |response|
95
+ raise Error, "#{where.path}: the gateway answered #{response.code}" unless response.is_a?(Net::HTTPSuccess)
96
+
97
+ return block_given? ? yield(response) : response.tap(&:body)
98
+ end
99
+ end
100
+ end
101
+
102
+ def door(target, url)
103
+ return Endpoints.call_log(url, target[:call]) if target[:call]
104
+
105
+ Endpoints.agent_log(url, target.fetch(:agent))
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ class Client
5
+ # Authenticated JSON requests to the gateway's REST endpoints. Non-2xx responses raise
6
+ # `Refused` with the gateway's message, never a bare status.
7
+ module Rest
8
+ # The header naming the world a request or a socket acts in.
9
+ ENV_HEADER = "pinecall-env"
10
+
11
+ module_function
12
+
13
+ def get(url, api_key:, env: nil) = ask(Net::HTTP::Get.new(URI.parse(url)), api_key:, env:)
14
+
15
+ def put(url, body, api_key:, env: nil)
16
+ request = Net::HTTP::Put.new(URI.parse(url))
17
+ request["content-type"] = "application/json"
18
+ request.body = JSON.generate(body)
19
+ ask(request, api_key:, env:)
20
+ end
21
+
22
+ def post(url, body, api_key:, env: nil)
23
+ request = Net::HTTP::Post.new(URI.parse(url))
24
+ request["content-type"] = "application/json"
25
+ request.body = JSON.generate(body)
26
+ ask(request, api_key:, env:)
27
+ end
28
+
29
+ def delete(url, api_key:, env: nil) = ask(Net::HTTP::Delete.new(URI.parse(url)), api_key:, env:)
30
+
31
+ # Returns the body with symbol keys (nil if empty); raises `Refused` on failure. `env` names
32
+ # the world the request acts in (`pinecall-env`); a server's token needs none.
33
+ def ask(request, api_key:, env: nil)
34
+ request["authorization"] = "Bearer #{api_key}"
35
+ request[ENV_HEADER] = env unless env.nil?
36
+ request["accept"] = "application/json"
37
+ where = request.uri
38
+ http = Net::HTTP.new(where.host, where.port)
39
+ http.use_ssl = where.scheme == "https"
40
+ answer = http.request(request)
41
+ body = parsed(answer.body)
42
+ return body if answer.is_a?(Net::HTTPSuccess)
43
+
44
+ raise Refused.new({ code: answer.code, message: sentence(body, answer) })
45
+ end
46
+
47
+ def parsed(text)
48
+ return nil if text.nil? || text.strip.empty?
49
+
50
+ JSON.parse(text, symbolize_names: true)
51
+ rescue JSON::ParserError
52
+ nil
53
+ end
54
+
55
+ # Gateway refusals use `detail`; otherwise pass the body through.
56
+ def sentence(body, answer)
57
+ (body.is_a?(Hash) && (body[:detail] || body[:message])) || answer.body.to_s.strip
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "socket"
7
+ require "uri"
8
+ require "websocket/driver"
9
+
10
+ require_relative "client/endpoints"
11
+ require_relative "client/listeners"
12
+ require_relative "client/connection"
13
+ require_relative "client/call"
14
+ require_relative "client/agent"
15
+ require_relative "client/observe"
16
+ require_relative "client/rest"
17
+
18
+ module Pinecall
19
+ # A connection to the Pinecall gateway: one socket, its agents, log reads, and a search for a call
20
+ # it serves. `Pinecall::Agent` is built on top of it.
21
+ #
22
+ # pc = Pinecall::Client.new(url: "https://cloud.pinecall.io", api_key: ENV.fetch("PINECALL_KEY"))
23
+ # agent = pc.agent("clinica-norte", tools: [])
24
+ # agent.on("turn.user") { |data, call| call.say("Le he oído: #{data[:text]}") }
25
+ # pc.connect
26
+ #
27
+ # It reads nothing from the environment: the app hands it the gateway and the key it keeps, and
28
+ # `env: "production"` names production for a person's key (a server's token needs none).
29
+ class Client
30
+ # The `sdk` field sent on register.
31
+ def sdk = "pinecall-ruby/#{VERSION}"
32
+
33
+ # A stop from the org: the socket is closed for good and the app decides what comes next.
34
+ STOPPED = "stopped"
35
+
36
+ # Seconds the running tools are given to answer once a drain was asked.
37
+ TOOLS_FINISH_WITHIN_S = 30
38
+
39
+ # Where a drain sent the calls, and what became of the tools running then.
40
+ Drained = Data.define(:handed, :parked, :tools, :finished)
41
+
42
+ attr_reader :url, :api_key, :env
43
+
44
+ def initialize(url:, api_key:, env: nil, ping_every: Connection::PINGS_EVERY_S, backoff: {})
45
+ raise ArgumentError, "Pinecall::Client.new(url:, api_key:): one of the two was not given" if url.nil? || api_key.nil?
46
+
47
+ @url = url
48
+ @api_key = api_key
49
+ @env = env
50
+ @agents = {}
51
+ @errors = []
52
+ @entries = []
53
+ @stops = []
54
+ @connects = []
55
+ @listeners = Listeners.new { |error| on_error(error) }
56
+ @connection = Connection.new(
57
+ url:, api_key:, env:, ping_every:, backoff:,
58
+ handlers: Connection::Handlers.new(
59
+ on_open: -> { opened },
60
+ on_entry: ->(entry) { took(entry) },
61
+ on_error: ->(error) { on_error(error) },
62
+ on_heartbeat: -> { @agents.each_value { |agent| guarded { agent.ping } } }
63
+ )
64
+ )
65
+ end
66
+
67
+ # Declare an agent. Nothing is sent until `connect`.
68
+ def agent(slug, **options)
69
+ @agents[slug] = Agent.new(slug, options, self)
70
+ end
71
+
72
+ # Declared agents by slug.
73
+ def agents = @agents.dup
74
+
75
+ # Open the socket and register every agent.
76
+ def connect
77
+ @connection.start
78
+ self
79
+ end
80
+
81
+ # Close the socket without reconnecting; agents are unregistered.
82
+ def close = @connection.close
83
+
84
+ # Leave without cutting calls: each agent drains (its live calls go to another holder, or wait
85
+ # for the next one) and the tools running get up to `tools_s` to answer. Close afterwards.
86
+ def drain(answer_s: Agent::ANSWERS_WITHIN_S, tools_s: TOOLS_FINISH_WITHIN_S)
87
+ @connection.leaving!
88
+ return Drained.new(handed: 0, parked: 0, tools: 0, finished: 0) unless connected?
89
+
90
+ answers = @agents.values.filter_map { |agent| guarded { drained(agent, answer_s) } }
91
+ running = @agents.values.sum(&:in_flight)
92
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + tools_s
93
+ @agents.each_value { |agent| agent.settled(within_s: [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
94
+ Drained.new(handed: answers.sum { |one| one[:handed] }, parked: answers.sum { |one| one[:parked] },
95
+ tools: running, finished: running - @agents.values.sum(&:in_flight))
96
+ end
97
+
98
+ # Every entry the socket receives, as the gateway wrote it, before any agent takes it — other
99
+ # agents' entries and the client's own errors included. Returns a lambda that unsubscribes.
100
+ def on_entries(&listener)
101
+ @entries << listener
102
+ -> { @entries.delete(listener) }
103
+ end
104
+
105
+ # Called each time the socket is up and every agent on it registered: the first connect and every
106
+ # reconnect after a drop. Returns a lambda that unsubscribes.
107
+ def on_connected(&listener)
108
+ @connects << listener
109
+ -> { @connects.delete(listener) }
110
+ end
111
+
112
+ # Called when a member of the org stops this app: the socket is closed for good. Returns a
113
+ # lambda that unsubscribes.
114
+ def on_stopped(&listener)
115
+ @stops << listener
116
+ -> { @stops.delete(listener) }
117
+ end
118
+
119
+ # True while the socket is up.
120
+ def connected? = @connection.open?
121
+
122
+ # Listen for one event type across all agents.
123
+ def on(type, &listener) = @listeners.on(type, &listener)
124
+
125
+ # Listen for every event across all agents.
126
+ def on_any(&listener) = @listeners.on_any(&listener)
127
+
128
+ # Listen for unhandled errors (bad frames, raising tools, socket loss). Without a listener
129
+ # they go to stderr. Returns a lambda that unsubscribes.
130
+ def on_errors(&listener)
131
+ @errors << listener
132
+ -> { @errors.delete(listener) }
133
+ end
134
+
135
+ # Stream a log, reduced by the wire's reducer. `target` is `{ call: "CA_1" }` or `{ agent: … }`.
136
+ def observe(target, **options, &block) = Observe.observe(target, url: @url, api_key: @api_key, **options, &block)
137
+
138
+ # Fetch one page of a log and its reduced state.
139
+ def history(target, **options) = Observe.history(target, url: @url, api_key: @api_key, **options)
140
+
141
+ # Search the agent's knowledge bases on behalf of a call this client serves; the gateway runs
142
+ # the search and logs it. Returns the chunks as the gateway answered them.
143
+ def search(call, query, k: nil)
144
+ input = k.nil? ? { query: } : { query:, k: }
145
+ answer = Rest.post(Endpoints.lookup(@url, call), { tool: "search", input: }, api_key: @api_key, env: @env)
146
+ answer.dig(:output, :chunks) || []
147
+ end
148
+
149
+ # ── used by agents ───────────────────────────────────────────────────────
150
+
151
+ # Send one command, validated against its schema.
152
+ def send_command(type:, agent:, call:, data:, id: nil)
153
+ @connection.send_frame(Wire.command(type:, agent:, call:, data:, id:))
154
+ end
155
+
156
+ # Forward an event to client-wide listeners.
157
+ def seen(event, call) = @listeners.emit(event, call)
158
+
159
+ def on_error(error)
160
+ return warn("pinecall: #{error.class}: #{error.message}") if @errors.empty?
161
+
162
+ @errors.each { |listener| listener.call(error) }
163
+ end
164
+
165
+ private
166
+
167
+ def took(entry)
168
+ @entries.each { |listener| guarded { listener.call(entry) } }
169
+ return stopped(entry.data[:message]) if stop?(entry)
170
+
171
+ agent = @agents[entry.agent]
172
+ # The socket may carry entries for agents this client did not declare.
173
+ return if agent.nil?
174
+
175
+ guarded { agent.take(entry) }
176
+ end
177
+
178
+ # An `error` coded `stopped` for no agent: a member of the org pressed Stop.
179
+ def stop?(entry) = entry.type == "error" && entry.agent.to_s.empty? && entry.data[:code] == STOPPED
180
+
181
+ # With no stop listener the stop is reported as an error, so it is never silent.
182
+ # Every agent registered again, then whoever asked to know.
183
+ def opened
184
+ @agents.each_value(&:open)
185
+ @connects.each { |listener| guarded { listener.call } }
186
+ end
187
+
188
+ def stopped(why)
189
+ @connection.close
190
+ return on_error(NotConnected.new(why)) if @stops.empty?
191
+
192
+ @stops.each { |listener| guarded { listener.call(why) } }
193
+ end
194
+
195
+ # The socket can close between `connected?` and the ask: then nothing was left to hand over. A
196
+ # gateway still connected that never answered is still said.
197
+ def drained(agent, answer_s)
198
+ agent.drain(answer_s:)
199
+ rescue NotConnected
200
+ raise if connected?
201
+
202
+ nil
203
+ end
204
+
205
+ def guarded
206
+ yield
207
+ rescue StandardError => e
208
+ on_error(e)
209
+ nil
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ # Base class for every error raised by this library. Messages say what went wrong and how to
5
+ # fix it.
6
+ class Error < StandardError
7
+ end
8
+
9
+ # The gateway refused a command with an `error` entry.
10
+ class Refused < Error
11
+ # @return [Hash{Symbol=>Object}] the wire refusal: code, message and id
12
+ attr_reader :refusal
13
+
14
+ def initialize(refusal)
15
+ @refusal = refusal
16
+ super("#{refusal[:code]}: #{refusal[:message]}")
17
+ end
18
+
19
+ # Machine-readable refusal code.
20
+ def code = @refusal[:code]
21
+ end
22
+
23
+ # A state field was assigned outside a tool or hook.
24
+ class UnauthoredWrite < Error
25
+ def initialize(field)
26
+ super("state field #{field} was assigned outside a tool and outside a lifecycle hook; " \
27
+ "tools are the only writers of state")
28
+ end
29
+ end
30
+
31
+ # A declaration the gateway would refuse, raised at load.
32
+ class DeclarationRefused < Error
33
+ end
34
+
35
+ # A console's ask refused with a status: raised by an `on_dev` handler, answered as `refused`.
36
+ class DevRefused < Error
37
+ attr_reader :status, :detail
38
+
39
+ def initialize(status, detail)
40
+ @status = status
41
+ @detail = detail
42
+ super("#{status}: #{detail}")
43
+ end
44
+ end
45
+
46
+ # A tool failed; the message is returned to the model as the tool result.
47
+ class ToolFailed < Error
48
+ end
49
+
50
+ # The socket is not connected.
51
+ class NotConnected < Error
52
+ end
53
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ # The console panel an agent draws beside a conversation: the TypeScript package's `@view` and
5
+ # its `@pinecall/agents/panels` tags. The nodes are the same JSON, drawn by the console's own
6
+ # components, so a tenant's panel cannot inject styles or scripts.
7
+ #
8
+ # panel "Ficha" do |who|
9
+ # patient = Crm.find(who.contact)
10
+ # panel patient.name do
11
+ # rows do
12
+ # row "Alta", patient.since
13
+ # row "Zona", patient.area
14
+ # end
15
+ # badge "con saldo", tone: :warn if patient.owes?
16
+ # end
17
+ # end
18
+ #
19
+ # A panel identifies a thread, not live call state: it is drawn for ended calls too, so it fetches
20
+ # its data from the tenant's own systems.
21
+ module Panel
22
+ # The conversation a panel is drawn for: the agent, the other party, the thread's newest call.
23
+ Who = Data.define(:agent, :contact, :call)
24
+
25
+ # A class's declared panel: its title and the block that draws it.
26
+ Declared = Data.define(:name, :draw)
27
+
28
+ TONES = %w[neutral good warn bad].freeze
29
+
30
+ # A value as the console shows it: nil and booleans are "", a whole float is written whole.
31
+ def self.said(value)
32
+ case value
33
+ when nil, true, false then ""
34
+ when Float then value.finite? && (value % 1).zero? ? value.to_i.to_s : value.to_s
35
+ else value.to_s
36
+ end
37
+ end
38
+
39
+ # Draw a class's panel for one conversation: `{ name:, nodes: }`, what `view.render` answers.
40
+ def self.draw(klass, who)
41
+ declared = klass.declared_panel
42
+ drawing = Drawing.new(klass)
43
+ drawing.instance_exec(who, &declared.draw)
44
+ { name: declared.name, nodes: drawing.nodes }
45
+ end
46
+
47
+ module Declaring
48
+ # Declare the class's console panel; the block draws it for a `Who`. One per class, and a
49
+ # subclass does not inherit it.
50
+ def panel(name = "View", &draw)
51
+ raise DeclarationRefused, "panel takes the block that draws it" if draw.nil?
52
+
53
+ standing = @pinecall_panel
54
+ unless standing.nil?
55
+ raise DeclarationRefused,
56
+ "#{self.name || "this class"} declares two panels (#{standing.name} and #{name}); a class draws one panel"
57
+ end
58
+
59
+ @pinecall_panel = Declared.new(name: name.to_s, draw:)
60
+ end
61
+
62
+ # The panel this class declares itself, or nil.
63
+ def declared_panel = @pinecall_panel
64
+ end
65
+
66
+ # What a panel's block draws on: one method per node of the console's closed catalogue. Any
67
+ # other method the block calls is the agent class's, where a tenant keeps its own helpers.
68
+ class Drawing
69
+ attr_reader :nodes
70
+
71
+ def initialize(klass)
72
+ @klass = klass
73
+ @nodes = []
74
+ end
75
+
76
+ # A titled section; several stack vertically.
77
+ def panel(title = nil, &) = add(tag: "panel", title: title.nil? ? nil : Panel.said(title), children: inside(&))
78
+
79
+ # A list of labelled rows.
80
+ def rows(&) = add(tag: "rows", children: inside(&))
81
+
82
+ # A labelled line: `row "Alta", "12 Mar 2024"`.
83
+ def row(label, value = nil) = add(tag: "row", label: Panel.said(label), value: Panel.said(value))
84
+
85
+ # A prominent labelled number.
86
+ def stat(label, value) = add(tag: "stat", label: Panel.said(label), value: Panel.said(value))
87
+
88
+ # A table. Each row is an array in column order, or a hash keyed by the columns' names.
89
+ def table(columns:, rows:)
90
+ add(tag: "table", columns: columns.map { |column| Panel.said(column) }, rows: rows.map { |row| cells(row, columns) })
91
+ end
92
+
93
+ # A status badge with a tone: neutral, good, warn or bad; a word it does not know is neutral.
94
+ def badge(text, tone: :neutral)
95
+ add(tag: "badge", tone: TONES.include?(tone.to_s) ? tone.to_s : "neutral", text: Panel.said(text))
96
+ end
97
+
98
+ # A line of text; an empty one draws nothing.
99
+ def text(said)
100
+ line = Panel.said(said).strip
101
+ line.empty? ? nil : add(tag: "text", text: line)
102
+ end
103
+
104
+ def respond_to_missing?(name, include_private = false) = @klass.respond_to?(name) || super
105
+
106
+ def method_missing(name, ...)
107
+ return super unless @klass.respond_to?(name)
108
+
109
+ @klass.public_send(name, ...)
110
+ end
111
+
112
+ private
113
+
114
+ def add(node)
115
+ @nodes << node
116
+ nil
117
+ end
118
+
119
+ # The nodes a nested block draws, collected apart from the ones around it.
120
+ def inside
121
+ outer = @nodes
122
+ @nodes = []
123
+ yield if block_given?
124
+ @nodes
125
+ ensure
126
+ @nodes = outer
127
+ end
128
+
129
+ def cells(row, columns)
130
+ case row
131
+ when Array then row.map { |cell| Panel.said(cell) }
132
+ when Hash then columns.map { |column| Panel.said(row.fetch(column) { row[column.to_s] || row[column.to_s.to_sym] }) }
133
+ else [Panel.said(row)]
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ # Read-only view of a state snapshot for `when:` and views: `s.slots` and `s[:slots]` are
5
+ # equivalent, and an undeclared field raises instead of returning nil.
6
+ class Reading
7
+ # @param remembered [Array<String>] facts known about the caller, supplied by the runtime
8
+ def initialize(state, remembered = [])
9
+ @state = state
10
+ @remembered = remembered
11
+ end
12
+
13
+ def to_h = @state
14
+
15
+ def [](name) = @state[name.to_sym]
16
+
17
+ def key?(name) = @state.key?(name.to_sym)
18
+
19
+ def fetch(name, *rest) = @state.fetch(name.to_sym, *rest)
20
+
21
+ # Whether a remembered fact contains `text`. Views may branch on facts but never print them;
22
+ # facts reach the model only as tool results.
23
+ def remembers?(text) = @remembered.any? { |fact| fact.to_s.include?(text.to_s) }
24
+
25
+ def respond_to_missing?(name, include_private = false)
26
+ @state.key?(name) || super
27
+ end
28
+
29
+ def method_missing(name, *args)
30
+ return @state[name] if args.empty? && @state.key?(name)
31
+
32
+ super
33
+ end
34
+
35
+ def inspect = "#<Pinecall::Reading #{@state.inspect}>"
36
+ end
37
+ end