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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agent/author"
4
+ require_relative "agent/doc"
5
+ require_relative "agent/state"
6
+ require_relative "agent/spec"
7
+ require_relative "agent/tools"
8
+ require_relative "agent/config"
9
+ require_relative "agent/knowledge"
10
+ require_relative "agent/searching"
11
+
12
+ module Pinecall
13
+ # Base class for an agent: fields are state, `tool` methods are the model's tools, comments
14
+ # are the prompt.
15
+ #
16
+ # class ClinicaNorte < Pinecall::Agent
17
+ # language "es"
18
+ #
19
+ # stage :identify, :book
20
+ # state :patient
21
+ #
22
+ # # Busca al paciente por nombre y teléfono.
23
+ # tool stage: :identify, pii: %i[name phone]
24
+ # def find_patient(name:, phone:)
25
+ # self.patient = Agenda.find(name, phone)
26
+ # self.stage = :book if patient
27
+ # patient
28
+ # end
29
+ # end
30
+ class Agent
31
+ extend Config::Declaring
32
+ extend State::Declaring
33
+ extend Tools::Declaring
34
+ extend Prompt::Declaring
35
+ extend Panel::Declaring
36
+ include State
37
+ include Tools
38
+
39
+ # Agent subclasses in definition order; `pinecall prompt agent.rb` uses it to find the class.
40
+ def self.written = @written ||= []
41
+
42
+ def self.inherited(subclass)
43
+ super
44
+ Agent.written << subclass
45
+ end
46
+
47
+ def initialize
48
+ @state = {}
49
+ @changes = []
50
+ @change_listeners = []
51
+ @log = []
52
+ @log_listeners = []
53
+ @event_listeners = []
54
+ @seq = 0
55
+ @sealed = false
56
+ @call = nil
57
+ @last_call = nil
58
+ self.class.declared_state.each { |name, default| @state[name] = opening(default) }
59
+ end
60
+
61
+ # The call being served. A method, not a field, so it never reaches a view; raises outside a call.
62
+ def call
63
+ raise Error, "there is no call here: the bridge hands an agent its call when one starts" if @call.nil?
64
+
65
+ @call
66
+ end
67
+
68
+ # Whether this instance is serving a call.
69
+ def call? = !@call.nil?
70
+
71
+ # The knowledge bases attached to this agent: `knowledge.search("…")` inside a tool.
72
+ def knowledge = Knowledge.new(self)
73
+
74
+ # Speak `text` verbatim now, as a `turn.agent`.
75
+ def say(text, **options) = call.say(text, **options)
76
+
77
+ # Make the model speak now, guided by instructions the caller does not hear.
78
+ def reply(instructions, **options) = call.reply(instructions, **options)
79
+
80
+ # Append a named entry to the call's log.
81
+ def log(name, data = nil)
82
+ entry = LogEntry.new(seq: next_seq, name: name.to_s, data:, at: Time.now.to_f)
83
+ @log << entry
84
+ @log_listeners.each { |listener| listener.call(entry) }
85
+ entry
86
+ end
87
+
88
+ # Everything this agent has logged, oldest first.
89
+ def logged = @log.dup
90
+
91
+ # Subscribe to logged entries; the returned lambda unsubscribes.
92
+ def on_log(&listener)
93
+ @log_listeners << listener
94
+ -> { @log_listeners.delete(listener) }
95
+ end
96
+
97
+ # Subscribe to external events, after `on_event` has run; the returned lambda unsubscribes.
98
+ def on_heard(&listener)
99
+ @event_listeners << listener
100
+ -> { @event_listeners.delete(listener) }
101
+ end
102
+
103
+ # The contact's previous call, read from the store passed to `mount(last:)`.
104
+ def last(contact)
105
+ raise Error, "last(contact) needs a store: mount the agent with last: to give it one" if @last_call.nil?
106
+
107
+ @last_call.call(contact)
108
+ end
109
+
110
+ # ── hooks: override them; state written inside one is attributed to the hook ──
111
+
112
+ # A call started.
113
+ def on_call(call) = nil
114
+
115
+ # The call ended.
116
+ def on_end(call) = nil
117
+
118
+ # A declared external event arrived.
119
+ def on_event(name, data, meta) = nil
120
+
121
+ # Memory was written; persist the ops wherever the application keeps them.
122
+ def on_memory(ops, call) = nil
123
+
124
+ # ── called by the bridge ─────────────────────────────────────────────────
125
+
126
+ # Run a hook with its state writes attributed to it.
127
+ def run_hook(hook, *args)
128
+ Author.with("hook:#{hook}") { public_send(hook, *args) }
129
+ end
130
+
131
+ # Set the call this instance serves; called once, at start.
132
+ def serving(call)
133
+ @call = call
134
+ self
135
+ end
136
+
137
+ # Set the store `last(contact)` reads.
138
+ def reads_last_from(source)
139
+ @last_call = source
140
+ self
141
+ end
142
+
143
+ # Notify `on_heard` listeners.
144
+ def notify_heard(name, data, meta)
145
+ @event_listeners.each { |listener| listener.call(name, data, meta) }
146
+ end
147
+
148
+ private
149
+
150
+ # Initial value for a field: Procs are called and mutable defaults duped, so calls never share one.
151
+ def opening(default)
152
+ return default.call if default.is_a?(Proc)
153
+
154
+ case default
155
+ when Array, Hash, String then default.dup
156
+ else default
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ # One named prompt block and its region (`static` or `dynamic`).
5
+ Block = Data.define(:name, :region, :text)
6
+
7
+ # One rendered prompt: blocks in send order, plus the history between the two regions.
8
+ Blocks = Data.define(:blocks, :history) do
9
+ def [](name) = blocks.find { |block| block.name == name.to_s }&.text
10
+
11
+ # Blocks before the history; cached by the provider.
12
+ def static = blocks.select { |block| block.region == "static" }
13
+
14
+ # Blocks after the history; replaced every turn.
15
+ def dynamic = blocks.select { |block| block.region == "dynamic" }
16
+
17
+ # Non-empty static blocks joined into one text.
18
+ def instructions = static.map(&:text).reject(&:empty?).join("\n\n")
19
+ end
20
+
21
+ # Renders the prompt as named blocks in two regions.
22
+ #
23
+ # Static blocks precede the history and are cached; the view follows it and changes every turn.
24
+ # Do not reorder them: the region boundary is the cache boundary. Blocks hold only the
25
+ # operator's text; lookup results go in the history as tool results.
26
+ module Prompt
27
+ # Blocks every agent has, in send order.
28
+ FRAMEWORK = [
29
+ { name: "identity", region: "static" }.freeze,
30
+ { name: "knowledge", region: "static" }.freeze,
31
+ { name: "tools", region: "static" }.freeze,
32
+ { name: "view", region: "dynamic" }.freeze
33
+ ].freeze
34
+
35
+ module Declaring
36
+ # Declare or read the template for the `view` block.
37
+ #
38
+ # view # views/<slug>.erb next to this file (the default)
39
+ # view "views/reception.erb" # relative to this file
40
+ # view template: <<~ERB # inline
41
+ #
42
+ # The default lookup runs once and is memoized.
43
+ def view(path = nil, template: nil)
44
+ unless path.nil? && template.nil?
45
+ @view = template ? View.inline(template, "#{name} (inline view)") : View.file(beside_this_file(path))
46
+ return @view
47
+ end
48
+ return @view if defined?(@view)
49
+
50
+ @view = view_of_this_class || (superclass.respond_to?(:view) ? superclass.view : nil)
51
+ end
52
+
53
+ def view_of_this_class
54
+ here = source_file
55
+ return nil if here.nil?
56
+
57
+ path = View.beside(here, slug)
58
+ File.exist?(path) ? View.file(path) : nil
59
+ end
60
+
61
+ # Relative paths resolve against the class's file, not the working directory.
62
+ def beside_this_file(path)
63
+ here = source_file
64
+ return path if here.nil? || path.start_with?("/")
65
+
66
+ File.expand_path(path, File.dirname(here))
67
+ end
68
+
69
+ def source_file
70
+ found = name && Object.const_source_location(name)
71
+ found&.first
72
+ end
73
+ end
74
+
75
+ module_function
76
+
77
+ def render(agent, resumed: false, remembered: [], line: nil)
78
+ reading = reading_for(agent, resumed:, remembered:, line:)
79
+ blocks = FRAMEWORK.map do |spec|
80
+ Block.new(name: spec[:name], region: spec[:region], text: text_of(agent, spec[:name], reading))
81
+ end
82
+ Blocks.new(blocks:, history: history(agent))
83
+ end
84
+
85
+ # The class docstring, the framework's rules and protocols, and the channel's. Reads no state.
86
+ def identity(agent)
87
+ [agent.doc, tagged("rules", Rules::RULES), tagged("protocols", Rules::PROTOCOLS), channel(agent)]
88
+ .compact.reject { |part| part.strip.empty? }.join("\n\n")
89
+ end
90
+
91
+ # Static because a call never changes its channel or medium. With no call (an offline prompt), a
92
+ # phone call's.
93
+ def channel(agent)
94
+ return nil if agent.class.channel_rules == false
95
+
96
+ call = agent.call? ? agent.call : nil
97
+ tagged("channel", Rules.channel_rules_for(call&.channel || "phone", call&.medium || "voice"))
98
+ end
99
+
100
+ # Every declared tool's docstring, visible or not; schemas travel separately on the wire.
101
+ def tools(agent)
102
+ docs = agent.tools.map { |spec| "- #{spec[:name]}: #{spec[:description]}" }.join("\n")
103
+ tagged("tools", docs)
104
+ end
105
+
106
+ # The runtime owns the turns; the framework adds only the summaries left by `collapse`.
107
+ def history(agent)
108
+ agent.changes.select { |change| change.field == "@summary" }
109
+ .map { |change| "#{collapsed(change.seq)}\n#{change.next}" }
110
+ .join("\n\n")
111
+ end
112
+
113
+ # Marker for a collapsed stretch of the call.
114
+ def collapsed(seq) = "<!-- collapsed: #{JSON.generate({ seq: })} -->"
115
+
116
+ def view(agent, reading)
117
+ view = agent.class.view
118
+ view.nil? ? "" : view.render(reading)
119
+ end
120
+
121
+ # The view's scope: state, call context, and remembered facts (queryable, never printed).
122
+ def reading_for(agent, resumed: false, remembered: [], line: nil)
123
+ Reading.new(agent.snapshot.merge(resumed:, call: line || { channel: "web" }), remembered)
124
+ end
125
+
126
+ # What `Pinecall::Serve`'s `prompt` prints: every block under its header.
127
+ def header_for(section, region = nil) = "── #{[section, region && "(#{region})"].compact.join(" ")} ──"
128
+
129
+ # The prompt as one page, as `pinecall prompt` prints it.
130
+ def show(agent, **context)
131
+ rendered = render(agent, **context)
132
+ sections = rendered.static.map { |block| [header_for(block.name, block.region), block.text] }
133
+ sections << [header_for("history"), rendered.history]
134
+ sections += rendered.dynamic.map { |block| [header_for(block.name, block.region), block.text] }
135
+ sections.map { |header, text| "#{header}\n#{text}".rstrip }.join("\n\n")
136
+ end
137
+
138
+ def text_of(agent, called, reading)
139
+ case called
140
+ when "identity" then identity(agent)
141
+ # Filled by the gateway from the agent's settings (`pinecall agent knowledge edit`).
142
+ when "knowledge" then ""
143
+ when "tools" then tools(agent)
144
+ when "view" then view(agent, reading)
145
+ end
146
+ end
147
+
148
+ def tagged(name, body)
149
+ body.to_s.empty? ? "" : "<#{name}>\n#{body}\n</#{name}>"
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ # Mounts an agent class on a client, with one instance per call. The only module that knows
5
+ # both the agent and the socket.
6
+ module Bridge
7
+ # A mounted agent and its live instances.
8
+ class Mounted
9
+ attr_reader :slug, :agent, :options
10
+
11
+ def initialize(slug:, agent:, options:, live:)
12
+ @slug = slug
13
+ @agent = agent
14
+ @options = options
15
+ @live = live
16
+ end
17
+
18
+ # The instance serving `call_id`, or nil.
19
+ def serving(call_id) = @live[call_id]&.agent
20
+ end
21
+
22
+ # One served call: its instance, what was last sent, and a thread running its jobs in order.
23
+ class Live
24
+ attr_reader :agent, :world, :sent, :stops
25
+ # What memory recalled about this caller in this call: what `remembers?` answers from.
26
+ attr_accessor :tools_shown, :remembered
27
+
28
+ def initialize(agent, world)
29
+ @agent = agent
30
+ @world = world
31
+ @sent = {}
32
+ @tools_shown = nil
33
+ @remembered = []
34
+ @stops = {}
35
+ @work = Thread::Queue.new
36
+ @thread = Thread.new { work }
37
+ end
38
+
39
+ # Queue a job; jobs run one at a time in order, which keeps `world.cause` accurate.
40
+ def later(&job) = @work << job
41
+
42
+ def finish
43
+ @work << :done
44
+ @thread.join(5)
45
+ end
46
+
47
+ private
48
+
49
+ def work
50
+ while (job = @work.pop)
51
+ break if job == :done
52
+
53
+ begin
54
+ job.call
55
+ rescue StandardError => e
56
+ warn("pinecall: #{e.class}: #{e.message}")
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ module_function
63
+
64
+ # Mount a class on a client; each call gets its own instance. Nothing is sent until `connect`.
65
+ #
66
+ # @param last [Proc] source for `last(contact)`
67
+ # @param takes_unclaimed [Boolean] false for a console, which serves only calls it opens
68
+ def mount(klass, client:, slug: nil, last: nil, takes_unclaimed: true)
69
+ name = slug || klass.slug
70
+ live = {}
71
+ # Throwaway instance used only to read the class's declarations.
72
+ probe = klass.new
73
+ options = klass.wire_config(tools: tools_for(probe, live)).merge(takes_unclaimed:)
74
+ agent = client.agent(name, **options)
75
+ agent.on("call.started") { |started, call| start(klass, live, call, client, last, started[:state]) }
76
+ agent.on("call.attached") { |_data, call| attached(klass, live, call, client, last) }
77
+ agent.on("call.ended") { |_data, call| finish(live, call) }
78
+ Mounted.new(slug: name, agent:, options:, live:)
79
+ end
80
+
81
+ # Tool specs with a `run` routed to the instance serving the calling call.
82
+ def tools_for(probe, live)
83
+ probe.tools.map do |spec|
84
+ spec.merge(run: lambda do |arguments, call|
85
+ serving = live[call.id]
86
+ raise ToolFailed, "#{spec[:name]}: this call is no longer being served" if serving.nil?
87
+
88
+ serving.agent.run_tool(spec[:name], arguments)
89
+ end)
90
+ end
91
+ end
92
+
93
+ # A call handed to this process mid-conversation: no `on_call`, the state the gateway kept
94
+ # instead. One served here already (the gateway restarted) keeps its instance, and its whole
95
+ # prompt is sent again, which the gateway may have lost.
96
+ def attached(klass, live, call, client, last)
97
+ held = live[call.id]
98
+ return held.later { resend(held, call) } unless held.nil?
99
+
100
+ instance = klass.new.seal
101
+ instance.reads_last_from(last) unless last.nil?
102
+ serving = Live.new(instance, world_for(call, client))
103
+ instance.serving(serving.world)
104
+ live[call.id] = serving
105
+ serving.later do
106
+ instance.restore(call.state)
107
+ sync(serving, call)
108
+ listen(serving, call)
109
+ end
110
+ end
111
+
112
+ def resend(serving, call)
113
+ serving.sent.clear
114
+ serving.tools_shown = nil
115
+ sync(serving, call)
116
+ end
117
+
118
+ def world_for(call, client)
119
+ searching = ->(query, k) { client.search(call.id, query, k:) }
120
+ CallWorld.new(id: call.id, contact: call.contact&.dig(:id) || call.from, from: call.from, channel: call.channel,
121
+ medium: call.medium, today: call.today, claimed: call.claimed, searching:) do |type, data|
122
+ call.command(type, data)
123
+ end
124
+ end
125
+
126
+ # Build the instance and run `on_call` before listening, so the first prompt is sent once
127
+ # rather than once per field. The state the opener asked for (`call.started.state`) is applied
128
+ # after the hook, which would overwrite it, and before the first render.
129
+ def start(klass, live, call, client, last, opened_in)
130
+ instance = klass.new.seal
131
+ instance.reads_last_from(last) unless last.nil?
132
+ world = world_for(call, client)
133
+ instance.serving(world)
134
+ serving = Live.new(instance, world)
135
+ live[call.id] = serving
136
+ serving.later do
137
+ instance.run_hook(:on_call, world)
138
+ instance.start_in(opened_in) unless opened_in.nil?
139
+ call.set_state(instance.snapshot)
140
+ sync(serving, call)
141
+ listen(serving, call)
142
+ end
143
+ end
144
+
145
+ # Re-sync the prompt on every state change.
146
+ def listen(serving, call)
147
+ instance = serving.agent
148
+ serving.stops[:state] = instance.on_change do |change|
149
+ call.set_state(instance.snapshot, [change.field])
150
+ # `state.set` has no cause field on the wire, so log it separately.
151
+ cause = serving.world.cause
152
+ call.log("state.cause", { field: change.field.to_s, kind: "event" }.merge(cause)) unless cause.nil?
153
+ sync(serving, call)
154
+ end
155
+ serving.stops[:log] = instance.on_log { |entry| call.log(entry.name, as_object(entry.data)) }
156
+ serving.stops[:entries] = call.on_any do |event|
157
+ serving.world.take(event.type, event.data, Time.now.to_f)
158
+ received(serving, event.data) if event.type == "event.received"
159
+ rerendered(serving, call, event)
160
+ end
161
+ end
162
+
163
+ # The view answers the turn being taken, not the one before it; a claim lets the caller see the
164
+ # page; recall is not state, so nothing else would render what it brought.
165
+ def rerendered(serving, call, event)
166
+ case event.type
167
+ when "turn.user", "call.claimed" then sync(serving, call)
168
+ when "memory.ops"
169
+ serving.remembered = words_recalled(event.data)
170
+ sync(serving, call)
171
+ end
172
+ end
173
+
174
+ # Each recalled fact's text and category; a `remember` op stores this call's facts, it surfaces none.
175
+ def words_recalled(data)
176
+ (data[:ops] || []).select { |op| op[:op] == "recall" }.flat_map do |op|
177
+ (op[:facts] || []).flat_map { |fact| [fact[:text], fact[:category] || ""] }
178
+ end
179
+ end
180
+
181
+ # Drop events whose (name, source) the class did not declare: an `:app` event arriving from
182
+ # a browser could be spoofed.
183
+ def received(serving, fact)
184
+ name = fact[:name].to_s
185
+ allowed = agent_class(serving).declared_events[name]
186
+ unless allowed&.include?(fact[:source].to_s)
187
+ return warn("pinecall: #{name} from #{fact[:source]} is not declared by this agent")
188
+ end
189
+
190
+ meta = { source: fact[:source], identity: fact[:identity], seq: serving.world.numbered }.compact
191
+ serving.later do
192
+ serving.world.cause = { event: name, event_seq: meta[:seq] }
193
+ serving.agent.run_hook(:on_event, name, fact[:data] || {}, meta)
194
+ serving.agent.notify_heard(name, fact[:data] || {}, meta)
195
+ serving.world.cause = nil
196
+ end
197
+ end
198
+
199
+ # Stop rendering and reading entries; the log stays open until `on_end` has run.
200
+ def finish(live, call)
201
+ serving = live.delete(call.id)
202
+ return if serving.nil?
203
+
204
+ serving.stops.values_at(:state, :entries).compact.each(&:call)
205
+ serving.later do
206
+ serving.agent.run_hook(:on_end, serving.world)
207
+ serving.stops[:log]&.call
208
+ end
209
+ serving.finish
210
+ end
211
+
212
+ # Send only blocks whose text changed since this call's last send: identical re-sends waste
213
+ # the provider's prompt cache. Unsent blocks count as empty.
214
+ def sync(serving, call)
215
+ line = { channel: call.channel || "web", from: call.from, claimed: serving.world.claimed }
216
+ rendered = Prompt.render(serving.agent, remembered: serving.remembered, line:)
217
+ rendered.blocks.each do |block|
218
+ next if block.text == serving.sent.fetch(block.name, "")
219
+
220
+ serving.sent[block.name] = block.text
221
+ call.set_prompt(block.name, block.text)
222
+ end
223
+ visible = serving.agent.visible_tools
224
+ return if visible == serving.tools_shown
225
+
226
+ serving.tools_shown = visible
227
+ call.set_tools(visible)
228
+ end
229
+
230
+ def agent_class(serving) = serving.agent.class
231
+
232
+ # `call.log` data must be an object; wrap scalars under `value`.
233
+ def as_object(data)
234
+ return {} if data.nil?
235
+
236
+ data.is_a?(Hash) ? data : { value: data }
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What a verb of the call waits for, and the answers it gets: a transfer, a supervisor, a search.
4
+
5
+ module Pinecall
6
+ class CallWorld
7
+ # Seconds `say`/`reply` wait for their turn before returning false.
8
+ LANDS_WITHIN_S = 30
9
+ # A transfer's far end may ring for 25 s before it fails.
10
+ TRANSFER_WITHIN_S = 90
11
+ # What `attention` waits beyond `wait_s` for the answer entry to arrive.
12
+ A_MOMENT_S = 15
13
+ # No answer came back: the gateway, not the far end, went quiet.
14
+ NO_ANSWER = "the runtime never said how it went"
15
+ # A pending verb when the call ends first.
16
+ THE_CALL_ENDED = "the call ended before it was answered"
17
+ # `search` on a call no gateway serves (an offline prompt, a test).
18
+ NO_GATEWAY_TO_SEARCH = "this call cannot search: no gateway is serving it"
19
+
20
+ # How a transfer went. `ok: false` means the caller is still with the agent.
21
+ Transferred = Data.define(:to, :mode, :ok, :error)
22
+
23
+ # Who took the line, or why nobody did.
24
+ Attended = Data.define(:ok, :by, :error)
25
+
26
+ # One search hit: its source path, heading and text.
27
+ Found = Data.define(:path, :heading, :text)
28
+
29
+ # The verbs waiting for an entry, by kind: `:spoken`, `:transfers`, `:asks`. The runtime always
30
+ # answers with an entry; the ceiling only guards against a gateway that went away.
31
+ class Waiting
32
+ def initialize
33
+ @waiting = Hash.new { |kinds, kind| kinds[kind] = [] }
34
+ @lock = Mutex.new
35
+ end
36
+
37
+ # Block until the answer of this kind arrives, or return `lapsed` after `ceiling_s`.
38
+ def wait(kind, ceiling_s, lapsed)
39
+ answer = Thread::Queue.new
40
+ @lock.synchronize { @waiting[kind] << answer }
41
+ yield
42
+ got = answer.pop(timeout: ceiling_s)
43
+ got.nil? ? lapsed : got
44
+ ensure
45
+ @lock.synchronize { @waiting[kind].delete(answer) }
46
+ end
47
+
48
+ # Answer every verb waiting for this kind.
49
+ def settle(kind, answer)
50
+ @lock.synchronize { @waiting.delete(kind) || [] }.each { |waiting| waiting.push(answer) }
51
+ end
52
+
53
+ # The call ended: every verb waiting is answered now instead of at its ceiling.
54
+ def end_all
55
+ settle(:spoken, false)
56
+ settle(:transfers, Transferred.new(to: "", mode: nil, ok: false, error: THE_CALL_ENDED))
57
+ settle(:asks, Attended.new(ok: false, by: nil, error: THE_CALL_ENDED))
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Who is in the call's room and what was said: the participants, a seat's two verbs, the turns.
4
+
5
+ module Pinecall
6
+ # A room participant, as reported by the room's entries.
7
+ Participant = Struct.new(:identity, :kind, :name, :joined_at, :speaking, keyword_init: true)
8
+
9
+ # A finished conversation turn.
10
+ Turn = Data.define(:who, :text, :speech_id, :interrupted, :at)
11
+
12
+ class CallWorld
13
+ # A participant handle. Removing the caller ends the call.
14
+ class Seat
15
+ def initialize(identity, send)
16
+ @identity = identity
17
+ @send = send
18
+ end
19
+
20
+ def mute(muted: true) = @send.call("participant.mute", { identity: @identity, muted: })
21
+
22
+ def remove = @send.call("participant.remove", { identity: @identity })
23
+ end
24
+ end
25
+ end