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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entries and commands read and written: a log line decoded, a command built and encoded.
4
+
5
+ module Pinecall
6
+ module Wire
7
+ # One log line. The streamed and stored forms are identical, and `seq` is assigned before the
8
+ # write returns, so two readers never disagree.
9
+ Entry = Data.define(:seq, :ts, :call, :agent, :type, :ephemeral, :data)
10
+
11
+ # One app-to-gateway instruction. The gateway answers with the events it produces, or with an
12
+ # `error` naming the command's id.
13
+ Command = Data.define(:type, :agent, :call, :id, :data)
14
+
15
+ # An entry read as what it means: the wire type, and the payload that type names.
16
+ Event = Data.define(:type, :data)
17
+
18
+ # JSON encoding and decoding. Keys are symbols in Ruby and strings on the wire; both are
19
+ # snake_case, so unlike TypeScript there is no key renaming.
20
+ module Codec
21
+ module_function
22
+
23
+ # One log line from parsed JSON. A shape the protocol does not declare is a WireError.
24
+ def decode_entry(raw)
25
+ fields = symbolize(raw)
26
+ Validate.call!("Entry", fields, where: "entry")
27
+ Entry.new(**fields)
28
+ end
29
+
30
+ # The entry's data as the shape its type names. An unknown type is a WireError.
31
+ def event_of(entry)
32
+ shape = Registry::EVENTS[entry.type]
33
+ raise WireError, "unknown event type: #{entry.type}" if shape.nil?
34
+
35
+ Validate.call!(shape, entry.data, where: entry.type)
36
+ Event.new(type: entry.type, data: entry.data)
37
+ end
38
+
39
+ # Build a command frame, validating its data and then the whole envelope. Validating here
40
+ # keeps the backtrace in the app's code.
41
+ def command(type:, agent:, data:, call: nil, id: nil)
42
+ shape = Registry::COMMANDS[type]
43
+ raise WireError, "unknown command type: #{type}" if shape.nil?
44
+
45
+ Validate.call!(shape, data, where: type)
46
+ frame = { type:, agent:, call:, data: }
47
+ frame[:id] = id unless id.nil?
48
+ Validate.call!("Command", frame, where: type)
49
+ Command.new(type:, agent:, call:, id:, data:)
50
+ end
51
+
52
+ # A frame as JSON text. A command without an id omits the key (the schema refuses null);
53
+ # `call` stays null, since a command about the agent itself belongs to no call.
54
+ def encode(frame)
55
+ fields = frame.to_h
56
+ fields.delete(:id) if frame.is_a?(Command) && fields[:id].nil?
57
+ JSON.generate(fields)
58
+ end
59
+
60
+ # Whether an entry of this type is one a store may drop and a slow reader may miss.
61
+ def ephemeral?(type)
62
+ Registry::EPHEMERAL_EVENTS.include?(type)
63
+ end
64
+
65
+ # JSON.parse can symbolize keys, but a hand-built Hash may use strings.
66
+ def symbolize(value)
67
+ case value
68
+ when Hash then value.to_h { |key, inner| [key.to_sym, symbolize(inner)] }
69
+ when Array then value.map { |inner| symbolize(inner) }
70
+ else value
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Every closed list of the wire this gem reads, as a frozen array.
4
+
5
+ module Pinecall
6
+ module Wire
7
+ module Enums
8
+ # The door the public came through: a phone call over SIP, the browser widget over WebRTC,
9
+ # or WhatsApp text.
10
+ CHANNEL = %w[phone web whatsapp].freeze
11
+ # Inbound: the public reached the agent. Outbound: the agent reached out (a dial).
12
+ DIRECTION = %w[inbound outbound].freeze
13
+ # How a call is had: spoken in a room, or written. A web call can be either.
14
+ MEDIUM = %w[voice text].freeze
15
+ # Which of the two worlds a key opens, and so which world an agent is held in and a call ran
16
+ # in. A key is issued into one; an agent registered on it and every call it takes carry that
17
+ # one; a door claimed in one is refused to a key of the other. `sandbox` is where things are
18
+ # written and `production` is what the public reaches — and whether a sandbox agent is one
19
+ # PERSON's copy or the team's shared one is not this field: it is whether the key that
20
+ # registered it names a person. Every key issued before the field existed is production.
21
+ ENV = %w[production sandbox].freeze
22
+ # What a console may ask of the process standing in the agent's directory, relayed by the
23
+ # gateway: a written call to the class mounted there (chat), a simulated caller put on the
24
+ # class it holds, its goldens and a suite of them, its knowledge folder pushed or its golden
25
+ # asked, its memory goldens, the panel it draws beside a conversation (view), a call
26
+ # promoted to a candidate file, the drift of the last two windows, and the reproductions a
27
+ # broken run left on that disk. Everything else a console needs is a door of the gateway.
28
+ DEV_VERB = %w[chat.roster chat.start chat.say chat.end view.render simulate.start goldens.roster goldens.run knowledge.roster knowledge.push knowledge.eval memory.roster memory.eval memory.extraction promote.roster promote.write drift.read reproductions.roster reproductions.read].freeze
29
+ # Why the call is over. Who hung up, what failed before anybody could, drained: the platform
30
+ # took the worker down (a deploy, a stop) with the call still on it, or app_detached: the
31
+ # app holding the agent closed its socket mid-call, so nothing was rendering the prompt or
32
+ # answering a tool — both are nobody's fault and neither is an error.
33
+ END_REASON = %w[caller_hung_up agent_hung_up supervisor_ended transferred no_answer busy dial_failed timeout drained app_detached error].freeze
34
+ # Whose action ended the call. platform covers timeouts, errors and a drained worker.
35
+ ENDED_BY = %w[caller agent supervisor platform].freeze
36
+ # What one judge answered about a finished call. Held: the rule held. Broken: it did not,
37
+ # and the reason names the evidence. Deferred: the judge was asked and could not settle it.
38
+ # Skipped: nobody asked it — no model was reachable inside the call's judging budget.
39
+ SCORE_VERDICT = %w[held broken deferred skipped].freeze
40
+ # Cold: the caller is sent on with a REFER on their SIP leg and the call ends here. Warm:
41
+ # the number is dialled into the call's own room, the agent stays on the line until the
42
+ # other side answers and then falls silent; the call ends when either of them hangs up.
43
+ TRANSFER_MODE = %w[cold warm].freeze
44
+ # Which region of the prompt a block lives in: static, before the history, cached by the
45
+ # provider; or dynamic, after the history, replaced every turn. The append-only history in
46
+ # between is never written by the app.
47
+ PROMPT_REGION = %w[static dynamic].freeze
48
+ # How the knowledge base reaches the model: retrieved, the platform runs search itself when
49
+ # the caller's turn ends; or tool, the model calls search when it decides to. Either way the
50
+ # chunks arrive as a tool result.
51
+ DOCS_MODE = %w[retrieved tool].freeze
52
+ # What the platform believes the person on the line is doing right now. The states are the
53
+ # session's own.
54
+ USER_STATE = %w[listening speaking away].freeze
55
+ # What the agent is doing right now, in the session's own words: warming up, waiting,
56
+ # hearing the caller, generating, or playing audio.
57
+ AGENT_STATE = %w[initializing idle listening thinking speaking].freeze
58
+ # Who a participant is to the call: the person the agent serves (over SIP or the widget),
59
+ # the agent itself, a supervisor who took a seat in the room, a listener who only hears, or
60
+ # a second SIP leg that room.invite brought in.
61
+ PARTICIPANT_KIND = %w[caller agent supervisor listener sip].freeze
62
+ # What a track carries: a microphone's audio, a camera's video, or a screen share.
63
+ TRACK_KIND = %w[audio video screen].freeze
64
+ # Where a track comes from, as livekit's TrackSource names it, in lower case.
65
+ TRACK_SOURCE = %w[microphone camera screen_share screen_share_audio unknown].freeze
66
+ # Where an outside fact came from: the tenant's backend over the app socket (app), or a
67
+ # participant's browser over the DataChannel (participant).
68
+ EVENT_SOURCE = %w[app participant].freeze
69
+ # Who may see a field of the app's state: everyone in the call (public), the tenant's own
70
+ # readers (tenant, the default for a field never declared), or nobody without masking (pii).
71
+ VISIBILITY = %w[public tenant pii].freeze
72
+ # Which projection a sink applies before a state or an entry leaves the platform: public for
73
+ # a participant reading its own call, tenant for the tenant's readers. The contract is
74
+ # docs/protocol/projections.md; a client never applies one.
75
+ PROJECTION = %w[public tenant].freeze
76
+ # Where the call is in its life. idle before any call.* entry, which is what an agent's own
77
+ # log looks like.
78
+ CALL_STATUS = %w[idle ringing dialing active ended].freeze
79
+ # Every closed list by the name the schema gave it, for a validator to look up.
80
+ ALL = {
81
+ "Channel" => CHANNEL,
82
+ "Direction" => DIRECTION,
83
+ "Medium" => MEDIUM,
84
+ "Env" => ENV,
85
+ "DevVerb" => DEV_VERB,
86
+ "EndReason" => END_REASON,
87
+ "EndedBy" => ENDED_BY,
88
+ "ScoreVerdict" => SCORE_VERDICT,
89
+ "TransferMode" => TRANSFER_MODE,
90
+ "PromptRegion" => PROMPT_REGION,
91
+ "DocsMode" => DOCS_MODE,
92
+ "UserState" => USER_STATE,
93
+ "AgentState" => AGENT_STATE,
94
+ "ParticipantKind" => PARTICIPANT_KIND,
95
+ "TrackKind" => TRACK_KIND,
96
+ "TrackSource" => TRACK_SOURCE,
97
+ "EventSource" => EVENT_SOURCE,
98
+ "Visibility" => VISIBILITY,
99
+ "Projection" => PROJECTION,
100
+ "CallStatus" => CALL_STATUS
101
+ }.freeze
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The one error of the wire: a frame, an entry or a body that does not fit its shape.
4
+
5
+ module Pinecall
6
+ module Wire
7
+ # A message did not match the protocol: an unknown type, a bad shape, an undeclared key.
8
+ # Raised where the frame is built or received, so the backtrace points at the caller.
9
+ class WireError < StandardError
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Folds a log into its State, entry by entry, as the runtime's reducer does.
4
+
5
+ module Pinecall
6
+ module Wire
7
+ # Folds a log into its State, entry by entry. Must match the TypeScript and Python reducers;
8
+ # the golden fixture checks all three.
9
+ module Reduce
10
+ # Error code recorded for an entry the reader cannot decode.
11
+ UNREADABLE = "unreadable"
12
+
13
+ module_function
14
+
15
+ # Fold every entry, in the order given, into the state an empty log starts from.
16
+ def reduce(entries)
17
+ entries.reduce(State.initial) { |state, entry| apply(state, entry) }
18
+ end
19
+
20
+ # Fold one entry in. A gap with a snapshot replaces the state; every other entry mutates it
21
+ # in place. The seq always advances.
22
+ def apply(state, entry)
23
+ event = begin
24
+ Codec.event_of(entry)
25
+ rescue WireError => why
26
+ return moved(unreadable(state, entry, why), entry)
27
+ end
28
+ following = event.type == "log.gap" ? on_log_gap(state, event.data) : apply_event(state, entry, event)
29
+ moved(following, entry)
30
+ end
31
+
32
+ # Old logs may hold entries in shapes this reader no longer accepts. Record one error and
33
+ # keep folding instead of rejecting the whole call; the Python and TypeScript reducers agree.
34
+ def unreadable(state, entry, why)
35
+ said = why.message.to_s.lines.first.to_s.strip
36
+ state[:errors] << {
37
+ seq: entry.seq,
38
+ code: UNREADABLE,
39
+ message: "#{entry.type} at seq #{entry.seq} is not the shape this reader knows: #{said}"
40
+ }
41
+ state
42
+ end
43
+
44
+ # Advance the fold to the entry's seq, whatever the entry was.
45
+ def moved(state, entry)
46
+ state[:seq] = entry.seq
47
+ state[:agent] = entry.agent
48
+ state[:call] = entry.call unless entry.call.nil?
49
+ state
50
+ end
51
+
52
+ # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
53
+ def apply_event(state, entry, event)
54
+ data = event.data
55
+ case event.type
56
+ # ── the call ──
57
+ when "call.ringing" then the_line(state, data, status: "ringing", direction: "inbound")
58
+ when "call.dialing" then the_line(state, data, status: "dialing", direction: "outbound")
59
+ when "call.started" then started(state, data)
60
+ when "call.ended" then ended(state, data)
61
+ when "call.transferred" then transferred(state, data)
62
+ when "call.line" then state.merge!(held: data[:held], muted: data[:muted])
63
+ when "call.summary" then summarised(state, data)
64
+ # ── the conversation ──
65
+ when "user.state" then state[:user_state] = data[:state]
66
+ when "agent.state" then state[:agent_state] = data[:state]
67
+ when "user.transcript" then state[:live][:user] = data[:final] ? nil : data[:text]
68
+ when "agent.transcript" then state[:live][:agent] = data[:final] ? nil : said_so_far(state[:live][:agent], data)
69
+ when "turn.user" then turn(state, data, "user")
70
+ when "turn.agent" then turn(state, data, "agent")
71
+ when "memory.ops" then state[:memory].concat(data[:ops])
72
+ when "docs.sources" then state[:sources] = data[:sources].dup
73
+ # ── metrics: every block is kept, in order, by kind ──
74
+ when "metrics.llm", "metrics.stt", "metrics.tts", "metrics.vad", "metrics.eou",
75
+ "metrics.eot", "metrics.interruption", "metrics.realtime", "metrics.avatar"
76
+ state[:metrics][event.type.delete_prefix("metrics.").to_sym] << data
77
+ # ── tools, state, confirmation ──
78
+ when "tool.call" then state[:tools] << data.merge(status: "running", seq: entry.seq)
79
+ when "tool.result" then on_tool_result(state, data)
80
+ when "state.changed" then state[:app_state] = data[:state].dup
81
+ when "prompt.changed" then on_prompt_changed(state, data, entry)
82
+ when "tools.changed" then state[:tools_visible] = data[:visible].dup
83
+ when "confirm.request" then requested(state, data)
84
+ when "confirm.granted" then settle(state, data[:call_id], { status: "granted", said: data[:said] })
85
+ when "confirm.declined" then declined(state, data)
86
+ # ── supervision, the agent, markers ──
87
+ when "supervisor.took_over" then state[:handoff] = { active: true, by: data[:by] }
88
+ when "supervisor.released" then state[:handoff] = { active: false, by: nil }
89
+ when "supervisor.transferred" then took_the_line(state, data)
90
+ when "attention.requested" then asked_for_a_person(state, data, entry.ts)
91
+ when "attention.answered" then answered(state, data)
92
+ when "agent.registered" then state[:routes] = data[:routes].dup
93
+ when "error" then state[:errors] << { seq: entry.seq, code: data[:code], message: data[:message] }
94
+ when "custom" then state[:custom] << { seq: entry.seq, name: data[:name], data: data[:data].dup }
95
+ # ── the room and the outside world ──
96
+ when "room.opened" then state[:room] = { name: data[:name], sid: data[:sid], participants: [], caller: nil }
97
+ when "participant.joined" then joined(state, data, entry.ts)
98
+ when "participant.left" then left(state, data[:identity])
99
+ when "participant.speaking" then speaking(state, data)
100
+ when "event.received" then state[:events] << data.except(:data).merge(seq: entry.seq)
101
+ end
102
+ state
103
+ end
104
+ # rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
105
+
106
+ # Not reduced (the case above leaves the state unchanged): supervisor.said/.whispered surface
107
+ # as turns and prompt changes, supervisor.ended as call.ended; agent.configured, pong,
108
+ # log.caught_up, track.published, track.unpublished and room.sent stay in the log only.
109
+
110
+ def the_line(state, line, status:, direction:)
111
+ state.merge!(
112
+ status:,
113
+ direction:,
114
+ channel: line[:channel],
115
+ from: line[:from],
116
+ to: line[:to],
117
+ caller: line[:caller]
118
+ )
119
+ end
120
+
121
+ def started(state, data)
122
+ the_line(state, data, status: "active", direction: data[:direction])
123
+ state[:started_at] = data[:started_at]
124
+ end
125
+
126
+ def ended(state, data)
127
+ state[:status] = "ended"
128
+ state[:ended_at] = data[:ended_at]
129
+ state[:end_reason] = data[:reason]
130
+ state[:live] = { user: nil, agent: nil }
131
+ # A caller who hung up while waiting for a person was never answered.
132
+ state[:attention] = state[:attention].merge(status: "lapsed") if state[:attention]&.fetch(:status) == "open"
133
+ end
134
+
135
+ def transferred(state, data)
136
+ state[:transfer] = {
137
+ to: data[:to],
138
+ mode: data[:mode],
139
+ status: data[:ok] ? "done" : "failed",
140
+ by: state[:transfer]&.fetch(:by, nil) || "agent"
141
+ }
142
+ end
143
+
144
+ def took_the_line(state, data)
145
+ state[:transfer] = { to: data[:to], mode: data[:mode], status: "requested", by: "supervisor" }
146
+ end
147
+
148
+ def asked_for_a_person(state, data, asked_at)
149
+ state[:attention] = { reason: data[:reason], wait_s: data[:wait_s], status: "open", asked_at: asked_at, by: nil }
150
+ end
151
+
152
+ def answered(state, data)
153
+ return if state[:attention].nil?
154
+
155
+ state[:attention] = state[:attention].merge(status: data[:ok] ? "answered" : "lapsed", by: data[:by])
156
+ end
157
+
158
+ def summarised(state, data)
159
+ state[:usage] = data[:usage].dup
160
+ state[:cost] = data[:cost]
161
+ state[:outcome] = data[:outcome]
162
+ state[:end_reason] ||= data[:reason]
163
+ end
164
+
165
+ # A delta: one word of a spoken reply or one token of a written one. Aligned voice words
166
+ # arrive bare and are joined with a space; written tokens carry their own spacing.
167
+ def said_so_far(so_far, data)
168
+ return data[:text] if so_far.nil? || so_far.empty?
169
+
170
+ apart = so_far.match?(/\s\z/) || data[:text].match?(/\A\s/)
171
+ !data[:start].nil? && !apart ? "#{so_far} #{data[:text]}" : "#{so_far}#{data[:text]}"
172
+ end
173
+
174
+ def turn(state, data, who)
175
+ state[:turns] << { role: who }.merge(data)
176
+ state[:live][who.to_sym] = nil
177
+ end
178
+
179
+ def requested(state, data)
180
+ state[:confirms] << {
181
+ tool: data[:tool],
182
+ call_id: data[:call_id],
183
+ audience: data[:audience],
184
+ phrase: data[:phrase],
185
+ status: "pending"
186
+ }
187
+ end
188
+
189
+ def declined(state, data)
190
+ verdict = { status: "declined", reason: data[:reason] }
191
+ verdict[:said] = data[:said] if data.key?(:said)
192
+ settle(state, data[:call_id], verdict)
193
+ end
194
+
195
+ def on_tool_result(state, result)
196
+ at = state[:tools].rindex { |run| run[:call_id] == result[:call_id] }
197
+ return if at.nil?
198
+
199
+ outcome = result.except(:call_id, :name)
200
+ state[:tools][at] = state[:tools][at].merge(outcome, status: outcome.key?(:error) ? "failed" : "done")
201
+ end
202
+
203
+ def settle(state, call_id, verdict)
204
+ at = state[:confirms].rindex { |confirm| confirm[:call_id] == call_id }
205
+ return if at.nil?
206
+
207
+ state[:confirms][at] = state[:confirms][at].merge(verdict)
208
+ end
209
+
210
+ def on_prompt_changed(state, data, entry)
211
+ state[:prompt][data[:name].to_sym] = { hash: data[:hash], chars: data[:chars], seq: entry.seq }
212
+ end
213
+
214
+ # joined_at is the entry's ts; the event does not repeat it.
215
+ def joined(state, joined, at)
216
+ return if state[:room].nil?
217
+
218
+ state[:room][:participants] << joined.merge(joined_at: at, speaking: false)
219
+ state[:room][:caller] = joined[:identity] if joined[:kind] == "caller"
220
+ end
221
+
222
+ # An identity is unique within a room: LiveKit disconnects the first of two that share one.
223
+ def left(state, identity)
224
+ return if state[:room].nil?
225
+
226
+ state[:room][:participants].reject! { |one| one[:identity] == identity }
227
+ state[:room][:caller] = nil if state[:room][:caller] == identity
228
+ end
229
+
230
+ def speaking(state, data)
231
+ who = state[:room]&.fetch(:participants)&.find { |one| one[:identity] == data[:identity] }
232
+ who[:speaking] = data[:speaking] unless who.nil?
233
+ end
234
+
235
+ # With a snapshot the state is replaced; without one only the gap is recorded.
236
+ def on_log_gap(state, data)
237
+ following = data[:snapshot].nil? ? state : deep_copy(data[:snapshot])
238
+ following[:gaps] << { from_seq: data[:from_seq], to_seq: data[:to_seq] }
239
+ following
240
+ end
241
+
242
+ # Copy the snapshot so later writes do not mutate the entry. Plain JSON data, so Marshal is a
243
+ # safe deep copy.
244
+ def deep_copy(value)
245
+ Marshal.load(Marshal.dump(value))
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Every event and command of the wire by its type, and the shape its data is.
4
+
5
+ require "set"
6
+
7
+ module Pinecall
8
+ module Wire
9
+ module Registry
10
+ # The shape each event's data is, by the event's wire type.
11
+ EVENTS = {
12
+ "agent.configured" => "AgentConfigured",
13
+ "agent.detached" => "AgentDetached",
14
+ "agent.draining" => "AgentDraining",
15
+ "agent.registered" => "AgentRegistered",
16
+ "agent.state" => "AgentStateChanged",
17
+ "agent.transcript" => "AgentTranscript",
18
+ "attention.answered" => "AttentionAnswered",
19
+ "attention.requested" => "AttentionRequested",
20
+ "call.attached" => "CallAttached",
21
+ "call.claimed" => "CallClaimed",
22
+ "call.dialing" => "CallDialing",
23
+ "call.ended" => "CallEnded",
24
+ "call.line" => "CallLine",
25
+ "call.ringing" => "CallRinging",
26
+ "call.score" => "CallScore",
27
+ "call.started" => "CallStarted",
28
+ "call.summary" => "CallSummary",
29
+ "call.transferred" => "CallTransferred",
30
+ "callback.requested" => "CallbackRequested",
31
+ "code.claimed" => "CodeClaimed",
32
+ "code.issued" => "CodeIssued",
33
+ "confirm.declined" => "ConfirmDeclined",
34
+ "confirm.granted" => "ConfirmGranted",
35
+ "confirm.request" => "ConfirmRequest",
36
+ "credits.exhausted" => "CreditsExhausted",
37
+ "custom" => "Custom",
38
+ "dev.request" => "DevRequest",
39
+ "docs.sources" => "DocsSources",
40
+ "dtmf.received" => "DtmfReceived",
41
+ "error" => "ErrorEvent",
42
+ "event.received" => "EventReceived",
43
+ "fleet.full" => "FleetFull",
44
+ "log.caught_up" => "LogCaughtUp",
45
+ "log.gap" => "LogGap",
46
+ "memory.ops" => "MemoryOps",
47
+ "message.taken" => "MessageTaken",
48
+ "message.waiting" => "MessageWaiting",
49
+ "metrics.avatar" => "AvatarMetrics",
50
+ "metrics.eot" => "EOTInferenceMetrics",
51
+ "metrics.eou" => "EOUMetrics",
52
+ "metrics.interruption" => "InterruptionMetrics",
53
+ "metrics.llm" => "LLMMetrics",
54
+ "metrics.realtime" => "RealtimeModelMetrics",
55
+ "metrics.stt" => "STTMetrics",
56
+ "metrics.tts" => "TTSMetrics",
57
+ "metrics.vad" => "VADMetrics",
58
+ "participant.joined" => "ParticipantJoined",
59
+ "participant.left" => "ParticipantLeft",
60
+ "participant.speaking" => "ParticipantSpeaking",
61
+ "pong" => "Pong",
62
+ "prompt.changed" => "PromptChanged",
63
+ "room.opened" => "RoomOpened",
64
+ "room.sent" => "RoomSent",
65
+ "spend.unusual" => "SpendUnusual",
66
+ "state.changed" => "StateChanged",
67
+ "supervisor.ended" => "SupervisorEnded",
68
+ "supervisor.released" => "SupervisorReleased",
69
+ "supervisor.said" => "SupervisorSaid",
70
+ "supervisor.took_over" => "SupervisorTookOver",
71
+ "supervisor.transferred" => "SupervisorTransferred",
72
+ "supervisor.whispered" => "SupervisorWhispered",
73
+ "tool.call" => "ToolCall",
74
+ "tool.result" => "ToolResult",
75
+ "tools.changed" => "ToolsChanged",
76
+ "track.published" => "TrackPublished",
77
+ "track.unpublished" => "TrackUnpublished",
78
+ "turn.agent" => "AgentTurnEnded",
79
+ "turn.user" => "UserTurnEnded",
80
+ "user.state" => "UserStateChanged",
81
+ "user.transcript" => "UserTranscript",
82
+ "vendor.switched" => "VendorSwitched"
83
+ }.freeze
84
+
85
+ # The shape each command's data is, by the command's wire type.
86
+ COMMANDS = {
87
+ "agent.configure" => "AgentConfigure",
88
+ "agent.drain" => "AgentDrain",
89
+ "agent.register" => "AgentRegister",
90
+ "agent.reply" => "AgentReply",
91
+ "agent.say" => "AgentSay",
92
+ "call.attention" => "CallAttention",
93
+ "call.callback" => "CallCallback",
94
+ "call.claim" => "CallClaim",
95
+ "call.dial" => "CallDial",
96
+ "call.dtmf" => "CallDtmf",
97
+ "call.event" => "CallEvent",
98
+ "call.hangup" => "CallHangup",
99
+ "call.hold" => "CallHold",
100
+ "call.log" => "CallLog",
101
+ "call.mute" => "CallMute",
102
+ "call.opt_out" => "CallOptOut",
103
+ "call.transfer" => "CallTransfer",
104
+ "call.unhold" => "CallUnhold",
105
+ "call.unmute" => "CallUnmute",
106
+ "dev.answer" => "DevAnswer",
107
+ "participant.mute" => "ParticipantMute",
108
+ "participant.remove" => "ParticipantRemove",
109
+ "ping" => "Ping",
110
+ "prompt.set" => "PromptSet",
111
+ "room.invite" => "RoomInvite",
112
+ "room.send" => "RoomSend",
113
+ "session.configure" => "SessionConfigure",
114
+ "state.set" => "StateSet",
115
+ "supervisor.verb" => "SupervisorVerb",
116
+ "tool.result" => "ToolResult",
117
+ "tools.set" => "ToolsSet"
118
+ }.freeze
119
+
120
+ # Every event this protocol declares, in the schema's own order.
121
+ EVENT_TYPES = %w[agent.configured agent.detached agent.draining agent.registered agent.state agent.transcript attention.answered attention.requested call.attached call.claimed call.dialing call.ended call.line call.ringing call.score call.started call.summary call.transferred callback.requested code.claimed code.issued confirm.declined confirm.granted confirm.request credits.exhausted custom dev.request docs.sources dtmf.received error event.received fleet.full log.caught_up log.gap memory.ops message.taken message.waiting metrics.avatar metrics.eot metrics.eou metrics.interruption metrics.llm metrics.realtime metrics.stt metrics.tts metrics.vad participant.joined participant.left participant.speaking pong prompt.changed room.opened room.sent spend.unusual state.changed supervisor.ended supervisor.released supervisor.said supervisor.took_over supervisor.transferred supervisor.whispered tool.call tool.result tools.changed track.published track.unpublished turn.agent turn.user user.state user.transcript vendor.switched].freeze
122
+
123
+ # Every command an app may send.
124
+ COMMAND_TYPES = %w[agent.configure agent.drain agent.register agent.reply agent.say call.attention call.callback call.claim call.dial call.dtmf call.event call.hangup call.hold call.log call.mute call.opt_out call.transfer call.unhold call.unmute dev.answer participant.mute participant.remove ping prompt.set room.invite room.send session.configure state.set supervisor.verb tool.result tools.set].freeze
125
+
126
+ # Entries a store may drop and a slow reader may miss without harm: the entry's
127
+ # own ephemeral flag defaults to this.
128
+ EPHEMERAL_EVENTS = %w[agent.transcript dev.request log.caught_up log.gap metrics.vad participant.speaking pong room.sent user.transcript].to_set.freeze
129
+
130
+ # The one event that ends a call: after it nothing more is true and the log seals.
131
+ TERMINAL_EVENT = "call.score"
132
+
133
+ # Which events a command lands in the log as, so a caller knows what to wait for.
134
+ PRODUCES = {
135
+ "agent.configure" => %w[agent.configured],
136
+ "agent.drain" => %w[agent.draining],
137
+ "agent.register" => %w[agent.registered],
138
+ "agent.reply" => %w[turn.agent],
139
+ "agent.say" => %w[turn.agent],
140
+ "call.attention" => %w[attention.requested call.line attention.answered],
141
+ "call.callback" => %w[callback.requested],
142
+ "call.claim" => %w[call.claimed],
143
+ "call.dial" => %w[call.dialing],
144
+ "call.dtmf" => [],
145
+ "call.event" => %w[event.received],
146
+ "call.hangup" => %w[call.ended],
147
+ "call.hold" => %w[call.line],
148
+ "call.log" => %w[custom],
149
+ "call.mute" => %w[call.line],
150
+ "call.transfer" => %w[call.transferred],
151
+ "call.unhold" => %w[call.line],
152
+ "call.unmute" => %w[call.line],
153
+ "dev.answer" => [],
154
+ "participant.mute" => %w[track.unpublished],
155
+ "participant.remove" => %w[participant.left],
156
+ "ping" => %w[pong],
157
+ "prompt.set" => %w[prompt.changed],
158
+ "room.invite" => %w[participant.joined],
159
+ "room.send" => %w[room.sent],
160
+ "session.configure" => %w[state.changed agent.configured],
161
+ "state.set" => %w[state.changed],
162
+ "supervisor.verb" => %w[supervisor.said supervisor.whispered supervisor.took_over supervisor.released supervisor.transferred supervisor.ended],
163
+ "tool.result" => %w[tool.result],
164
+ "tools.set" => %w[tools.changed]
165
+ }.freeze
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Every named shape of the wire, as the table Validate walks. A shape is closed: a key nobody
4
+ # declared is refused by name, since it means the gateway speaks a newer wire than this gem.
5
+
6
+ require_relative "shapes_parts"
7
+ require_relative "shapes_call_events"
8
+ require_relative "shapes_events"
9
+ require_relative "shapes_commands"
10
+ require_relative "shapes_doors"
11
+ require_relative "shapes_config"
12
+ require_relative "shapes_metrics"
13
+
14
+ module Pinecall
15
+ module Wire
16
+ module Shapes
17
+ SHAPES = [PARTS, CONFIG, METRICS, CALL_EVENTS, EVENTS, COMMANDS, DOORS].reduce(:merge).freeze
18
+
19
+ # A union is told apart by one field, the way the schema's discriminator says.
20
+ UNIONS = {
21
+ "ModelUsage" => { on: :type, members: %w[LLMModelUsage TTSModelUsage STTModelUsage InterruptionModelUsage EOTModelUsage] }.freeze,
22
+ "Turn" => { on: :role, members: %w[UserTurn AgentTurn] }.freeze,
23
+ "Verb" => { on: :verb, members: %w[SayVerb WhisperVerb TakeoverVerb ReleaseVerb TransferVerb EndVerb] }.freeze,
24
+ "StateCause" => { on: :kind, members: %w[StateCauseTool StateCauseEvent] }.freeze
25
+ }.freeze
26
+
27
+ # A map is keyed by names the app chose; every value is the one shape given here.
28
+ MAPS = {
29
+ "PromptState" => { kind: :ref, ref: "PromptBlockState" }.freeze
30
+ }.freeze
31
+ end
32
+ end
33
+ end