pinecall 0.11.14 → 0.11.16

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f97d72a0ab4004e51b2f9bb07d601cb8c6007b10babece8ca03e6071f4f05aa4
4
- data.tar.gz: 966cb68d6ac119c9d5c838910b242eaf3cf620a7688436f135475d47f3e9846e
3
+ metadata.gz: ccadca23b8a8165ca26d6378d9309a21fe194aa7f36768186feec8ded71113a9
4
+ data.tar.gz: db44663b616cdaaed4b616cddc7ef84105a2eb59c50228b359c975a4c433888a
5
5
  SHA512:
6
- metadata.gz: 58596d5793c2003e72ae9c1cdcfe938db4834bf9e0b64177759daecfbdb7f7d83dd6869d8cd55ce6029eab7b81616da0dd1f80a378166efe572db593cf2257e0
7
- data.tar.gz: f8c4ee0d9f134ac38c861d5b6bcb956c382299d4dd2fd6518af14e06afa7672f8d633be515f0590c56d9097289759a14294a0fb0fbfedc58f9d27119c6e7c793
6
+ metadata.gz: 6b81c136cc6986a767110f1dc169367c00799a45e42897150b506cfec1949d4f3470bddfe40dd864ae510240eb8fa93979a043e62de5d0a9c30b0fec71ba9024
7
+ data.tar.gz: 9b6afb0e418e3820b427e82bdbf868aa912f68459ce7e9f4388ea6c7e605be4fc3aaab6505e5e8e2b4d2b23edfbe86327a1fc35542d1253f0870788b93e538ea
data/CHANGELOG.md CHANGED
@@ -4,6 +4,64 @@ Every change a person using this gem would notice. The format is
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version numbers and tags are the
5
5
  maintainer's call.
6
6
 
7
+ ## [0.11.16] — no object prints the API key, a failing view or hook fails its call, values reach the wire as fields (2026-10-23)
8
+
9
+ - **SECURITY: no object of the gem prints the API key any more.** `Client` and its connection held the key
10
+ in an instance variable and Ruby's default `inspect` printed it (and the websocket driver's
11
+ `Authorization` header), as did every object holding a client (`Serve::Held`, `Client::Agent`, a `p`, an
12
+ exception that interpolated one, a log line); both now say their door and their agents, never the key.
13
+ (found by the 2026-10-23 eval, J F8 side note)
14
+ - **The serve entry no longer prints the values an agent declared private.** `--events` printed every entry
15
+ as the gateway wrote it, so a tool's `pii` arguments, the state fields declared `pii` and a private value
16
+ echoed in a tool's answer or a logged line reached the terminal; they are masked as the call's log masks
17
+ them (by name, and by value as a whole word once a tool call carried it, never a value shorter than three
18
+ characters). (found by the 2026-10-23 eval, A F3, B D7, L D7)
19
+ - **A `Data` or a `Struct` reaches the wire as its fields, wherever a Ruby value becomes JSON.** A tool's
20
+ return, a state field, a logged line or a payload holding one was sent as its `inspect` string
21
+ (`knowledge.search(…)` handed the model `"#<data Pinecall::CallWorld::Found …>"`); every command's data
22
+ now passes through one `Wire::Codec.plain` before it is validated, and `call.log` of a `Data` or a
23
+ `Struct` is its fields. (found by the 2026-10-23 eval, D D-2)
24
+ - **A view that raises fails its own call and nothing else.** The call's log gets a `render.failed` entry
25
+ with the exception's class and message, the model keeps the view it had and the process goes on; before,
26
+ the first render's failure left the call without a prompt and without a trace in its log. (found by the
27
+ 2026-10-23 eval, B B8)
28
+ - **A hook that raises fails its own call and nothing else.** `on_call`, `on_event`, `on_memory` and `on_end`
29
+ raised through the call's job thread, which only printed a line: the call was left without a prompt, an
30
+ `on_event`'s cause stayed on the next write and an `on_end` never said `call.done`. The call's log now
31
+ gets a `hook.failed` entry naming the hook (and the event) and the error, and the call goes on with what
32
+ the hook wrote. (found by the 2026-10-23 eval, B B8)
33
+ - **Every state write names its author on the wire.** Each `state.set` carries `cause`: the tool and the
34
+ model's id for its call, the outside fact (name and the entry's seq), or the hook (`on_call`,
35
+ `on_memory`); only the first write of a tool had one, from the runtime, and a hook's none. The
36
+ `state.cause` log line is gone, `on_event`'s `meta[:seq]` is the entry's seq, and the wire knows
37
+ `StateCauseHook`, `StateSet.cause` and `CallStarted.metadata`. A client tool's `run` takes the model's
38
+ call id as a third argument. (found by the 2026-10-23 eval, B B4)
39
+ - **A line logged in `on_call` lands in the call's log.** The log was forwarded only after the hook had run.
40
+ (found by the 2026-10-23 eval, B B3)
41
+ - **The gem sends `call.done` after the end hook, and registers `says_done`**, so the gateway's seal waits
42
+ for what the hook logs before it seals the log. (found by the 2026-10-23 eval, S2)
43
+ - **`call.to`, `call.direction`, `call.caller` and `call.metadata`** are on the call a tool or a hook reads,
44
+ as in the TypeScript package; the testing gateway's `call_started` takes `direction:` and `metadata:`.
45
+ (found by the 2026-10-23 eval, K F6)
46
+ - **A stop from the org leaves no `IOError: closed stream` behind.** The driver's answer to the gateway's
47
+ close frame was written to a socket this side had already closed and reported as an error; a write after
48
+ this side closed on purpose is not one. (found by the 2026-10-23 eval, J F2)
49
+ - **`Pinecall.serve`'s `held.stop` says where the calls went**, one line on stderr as the serve entry does
50
+ (`draining · no live calls`); `Serve::Held.new` takes `err:`. (found by the 2026-10-23 eval, J F8)
51
+ - **The serve entry says when an admin kills one of its agents**: one line on stderr, `<slug> ✗ <slug> was
52
+ killed by <name>`, and `Client#on_killed` hands over the slug and the sentence. (found by the 2026-10-23
53
+ eval, J F3)
54
+ - **The app no longer answers `end_call`.** The platform writes that call and its result itself; an error
55
+ result from the app was a second one. (found by the 2026-10-23 eval, R2)
56
+ - **The testing page shows what `call.tool` returns when the tool succeeds**: `{ call_id:, name:, output:,
57
+ duration_s: }`, the tool's value under `[:output]`. (found by the 2026-10-23 eval, D R0-1)
58
+
59
+ ## [0.11.15] — the serve entry registers as its host (2026-10-23)
60
+
61
+ - **The serve entry registers as `PINECALL_HOST` when it is set**, else as this machine's
62
+ hostname (`Client#host`). A hosted app's runner names each host, and the gateway knows a release
63
+ is serving by it, on a machine that runs several.
64
+
7
65
  ## [0.11.14] — the wire knows `KnowledgePushed.kept` (2026-10-23)
8
66
 
9
67
  - **`KnowledgePushed.kept`**: the files of the base a push did not name and kept.
@@ -8,6 +8,7 @@ module Pinecall
8
8
  # `Fiber[]` is inherited by child fibers and separate per thread.
9
9
  module Author
10
10
  KEY = :pinecall_author
11
+ CALL = :pinecall_author_call
11
12
 
12
13
  module_function
13
14
 
@@ -16,14 +17,20 @@ module Pinecall
16
17
  Fiber[KEY]
17
18
  end
18
19
 
19
- # Run the block with state writes attributed to `name`.
20
- def with(name)
21
- held = Fiber[KEY]
20
+ # The model's id for the tool call the current author is running, or nil.
21
+ def call_id
22
+ Fiber[CALL]
23
+ end
24
+
25
+ # Run the block with state writes attributed to `name`; a tool also says the model's `call_id`.
26
+ def with(name, call_id = nil)
27
+ held = [Fiber[KEY], Fiber[CALL]]
22
28
  Fiber[KEY] = name
29
+ Fiber[CALL] = call_id
23
30
  begin
24
31
  yield
25
32
  ensure
26
- Fiber[KEY] = held
33
+ Fiber[KEY], Fiber[CALL] = held
27
34
  end
28
35
  end
29
36
  end
@@ -98,7 +98,7 @@ module Pinecall
98
98
  # back as JSON is a string, and is the declared stage it names.
99
99
  def restore(state)
100
100
  state = state.transform_keys(&:to_sym)
101
- Author.with(Author.current || "restore") do
101
+ Author.with(Author.current || "restore", Author.call_id) do
102
102
  self.class.declared_state.each_key do |name|
103
103
  value = state.key?(name) ? state[name] : nil
104
104
  write_state(name, name == :stage ? declared_stage(value) : value)
@@ -70,12 +70,13 @@ module Pinecall
70
70
  self.class.declared_tools.values.select { |declared| Tools.shows?(declared, state) }
71
71
  end
72
72
 
73
- # Run a tool as the bridge does, with its writes attributed to it.
74
- def run_tool(name, arguments = {})
73
+ # Run a tool as the bridge does, with its writes attributed to it; `call_id` is the model's id
74
+ # for the call, when there is one.
75
+ def run_tool(name, arguments = {}, call_id = nil)
75
76
  declared = self.class.tool_named(name)
76
77
  raise ToolFailed, "#{name}: this agent declares no such tool" if declared.nil?
77
78
 
78
- Tools.run(self, declared, arguments)
79
+ Tools.run(self, declared, arguments, call_id)
79
80
  end
80
81
 
81
82
  class << self
@@ -93,10 +94,10 @@ module Pinecall
93
94
  end
94
95
 
95
96
  # Check arguments, run the method with its writes attributed, and trim to `preview`.
96
- def run(agent, declared, arguments)
97
+ def run(agent, declared, arguments, call_id = nil)
97
98
  named = arguments.to_h { |key, value| [key.to_sym, value] }
98
99
  refuse_arguments_the_tool_never_asked_for(declared, named)
99
- result = Author.with(declared.name.to_s) { agent.public_send(declared.name, **named) }
100
+ result = Author.with(declared.name.to_s, call_id) { agent.public_send(declared.name, **named) }
100
101
  preview(result, declared.options[:preview])
101
102
  end
102
103
 
@@ -70,7 +70,7 @@ module Pinecall
70
70
  live = {}
71
71
  # Throwaway instance used only to read the class's declarations.
72
72
  probe = klass.new
73
- options = klass.wire_config(tools: tools_for(probe, live)).merge(takes_unclaimed:)
73
+ options = klass.wire_config(tools: tools_for(probe, live)).merge(takes_unclaimed:, says_done: true)
74
74
  agent = client.agent(name, **options)
75
75
  agent.on("call.started") { |started, call| start(klass, live, call, client, last, started[:state]) }
76
76
  agent.on("call.attached") { |_data, call| attached(klass, live, call, client, last) }
@@ -81,11 +81,11 @@ module Pinecall
81
81
  # Tool specs with a `run` routed to the instance serving the calling call.
82
82
  def tools_for(probe, live)
83
83
  probe.tools.map do |spec|
84
- spec.merge(run: lambda do |arguments, call|
84
+ spec.merge(run: lambda do |arguments, call, call_id|
85
85
  serving = live[call.id]
86
86
  raise ToolFailed, "#{spec[:name]}: this call is no longer being served" if serving.nil?
87
87
 
88
- serving.agent.run_tool(spec[:name], arguments)
88
+ serving.agent.run_tool(spec[:name], arguments, call_id)
89
89
  end)
90
90
  end
91
91
  end
@@ -103,6 +103,7 @@ module Pinecall
103
103
  instance.serving(serving.world)
104
104
  live[call.id] = serving
105
105
  serving.later do
106
+ forward_log(serving, call)
106
107
  instance.restore(call.state)
107
108
  sync(serving, call)
108
109
  listen(serving, call)
@@ -117,7 +118,8 @@ module Pinecall
117
118
 
118
119
  def world_for(call, client)
119
120
  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
+ CallWorld.new(id: call.id, contact: call.contact&.dig(:id) || call.from, from: call.from, to: call.to,
122
+ channel: call.channel, direction: call.direction, caller: call.contact, metadata: call.metadata,
121
123
  medium: call.medium, today: call.today, claimed: call.claimed, searching:) do |type, data|
122
124
  call.command(type, data)
123
125
  end
@@ -125,7 +127,8 @@ module Pinecall
125
127
 
126
128
  # Build the instance and run `on_call` before listening, so the first prompt is sent once
127
129
  # 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.
130
+ # after the hook, which would overwrite it, and before the first render. The log is open from the
131
+ # hook on: a line `on_call` writes is the call's.
129
132
  def start(klass, live, call, client, last, opened_in)
130
133
  instance = klass.new.seal
131
134
  instance.reads_last_from(last) unless last.nil?
@@ -134,32 +137,56 @@ module Pinecall
134
137
  serving = Live.new(instance, world)
135
138
  live[call.id] = serving
136
139
  serving.later do
137
- instance.run_hook(:on_call, world)
140
+ forward_log(serving, call)
141
+ hooked(serving, call, :on_call, world)
138
142
  instance.start_in(opened_in) unless opened_in.nil?
139
- call.set_state(instance.snapshot)
143
+ call.set_state(instance.snapshot, *opening(instance))
140
144
  sync(serving, call)
141
145
  listen(serving, call)
142
146
  end
143
147
  end
144
148
 
145
- # Re-sync the prompt on every state change.
149
+ # What the opening `state.set` says: when `on_call` wrote, the fields written and the hook as the
150
+ # cause. Nothing written, nothing said: the whole state is the news.
151
+ def opening(instance)
152
+ return [] unless instance.changes.any? { |change| change.author == "hook:on_call" }
153
+
154
+ [instance.changes.map(&:field).uniq, { kind: "hook", hook: "on_call" }]
155
+ end
156
+
157
+ # Re-sync the prompt on every state change, each one sent with who wrote it.
146
158
  def listen(serving, call)
147
159
  instance = serving.agent
148
160
  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?
161
+ call.set_state(instance.snapshot, [change.field], cause_of(change, serving))
153
162
  sync(serving, call)
154
163
  end
155
- serving.stops[:log] = instance.on_log { |entry| call.log(entry.name, as_object(entry.data)) }
156
164
  serving.stops[:entries] = call.on_any do |event|
157
165
  serving.world.take(event.type, event.data, Time.now.to_f)
158
- received(serving, event.data) if event.type == "event.received"
166
+ received(serving, call, event) if event.type == "event.received"
159
167
  rerendered(serving, call, event)
160
168
  end
161
169
  end
162
170
 
171
+ # Who made a write, as the wire's `StateCause` says it: the lifecycle hook, the outside fact the hook is
172
+ # handling, or the tool and the model's id for the call that ran it. A write the wire has no word for
173
+ # (a restore) says nothing.
174
+ def cause_of(change, serving)
175
+ author = change.author.to_s
176
+ case author
177
+ when "hook:on_call", "hook:on_memory" then { kind: "hook", hook: author.delete_prefix("hook:") }
178
+ when "hook:on_event" then (fact = serving.world.cause) && { kind: "event", **fact }
179
+ else
180
+ call_id = Agent::Author.call_id
181
+ { kind: "tool", tool: author, call_id: } if call_id && serving.agent.class.tool_named(author)
182
+ end
183
+ end
184
+
185
+ # What the class logs reaches the call's log until `on_end` has run.
186
+ def forward_log(serving, call)
187
+ serving.stops[:log] = serving.agent.on_log { |entry| call.log(entry.name, as_object(entry.data)) }
188
+ end
189
+
163
190
  # The view answers the turn being taken, not the one before it; a claim lets the caller see the
164
191
  # page; recall is not state, so nothing else would render what it brought. What memory did then
165
192
  # reaches `on_memory`, after the view already says it.
@@ -169,7 +196,7 @@ module Pinecall
169
196
  when "memory.ops"
170
197
  serving.remembered = words_recalled(event.data)
171
198
  sync(serving, call)
172
- serving.later { serving.agent.run_hook(:on_memory, event.data[:ops] || [], serving.world) }
199
+ serving.later { hooked(serving, call, :on_memory, event.data[:ops] || [], serving.world) }
173
200
  end
174
201
  end
175
202
 
@@ -182,19 +209,23 @@ module Pinecall
182
209
 
183
210
  # Drop events whose (name, source) the class did not declare: an `:app` event arriving from
184
211
  # a browser could be spoofed.
185
- def received(serving, fact)
212
+ def received(serving, call, event)
213
+ fact = event.data
186
214
  name = fact[:name].to_s
187
215
  allowed = agent_class(serving).declared_events[name]
188
216
  unless allowed&.include?(fact[:source].to_s)
189
217
  return warn("pinecall: #{name} from #{fact[:source]} is not declared by this agent")
190
218
  end
191
219
 
192
- meta = { source: fact[:source], identity: fact[:identity], seq: serving.world.numbered }.compact
220
+ meta = { source: fact[:source], identity: fact[:identity], seq: event.seq }.compact
193
221
  serving.later do
194
- serving.world.cause = { event: name, event_seq: meta[:seq] }
195
- serving.agent.run_hook(:on_event, name, fact[:data] || {}, meta)
196
- serving.agent.notify_heard(name, fact[:data] || {}, meta)
197
- serving.world.cause = nil
222
+ serving.world.cause = { name:, seq: event.seq }
223
+ begin
224
+ hooked(serving, call, :on_event, name, fact[:data] || {}, meta, event: name)
225
+ serving.agent.notify_heard(name, fact[:data] || {}, meta)
226
+ ensure
227
+ serving.world.cause = nil
228
+ end
198
229
  end
199
230
  end
200
231
 
@@ -205,8 +236,11 @@ module Pinecall
205
236
 
206
237
  serving.stops.values_at(:state, :entries).compact.each(&:call)
207
238
  serving.later do
208
- serving.agent.run_hook(:on_end, serving.world)
239
+ hooked(serving, call, :on_end, serving.world)
209
240
  serving.stops[:log]&.call
241
+ # After every line the hook logged, whether it ran or raised: the gateway seals the call on this
242
+ # word, and a line sent after the seal is refused.
243
+ call.done
210
244
  end
211
245
  serving.finish
212
246
  end
@@ -215,27 +249,48 @@ module Pinecall
215
249
  # the provider's prompt cache. Unsent blocks count as empty.
216
250
  def sync(serving, call)
217
251
  line = { channel: call.channel || "web", from: call.from, claimed: serving.world.claimed }
218
- rendered = Prompt.render(serving.agent, remembered: serving.remembered, line:)
252
+ begin
253
+ rendered = Prompt.render(serving.agent, remembered: serving.remembered, line:)
254
+ visible = serving.agent.visible_tools
255
+ rescue StandardError => e
256
+ return failed_to_render(call, e)
257
+ end
219
258
  rendered.blocks.each do |block|
220
259
  next if block.text == serving.sent.fetch(block.name, "")
221
260
 
222
261
  serving.sent[block.name] = block.text
223
262
  call.set_prompt(block.name, block.text)
224
263
  end
225
- visible = serving.agent.visible_tools
226
264
  return if visible == serving.tools_shown
227
265
 
228
266
  serving.tools_shown = visible
229
267
  call.set_tools(visible)
230
268
  end
231
269
 
270
+ # A view that fails fails its call, never the process: the model keeps the view it had, and the log says why.
271
+ def failed_to_render(call, error)
272
+ call.log("render.failed", { error: error.class.to_s, message: error.message })
273
+ warn("pinecall: #{call.id} could not render its view and goes on with the one it had: #{error.message}")
274
+ end
275
+
276
+ # A hook that raises fails its call, never the process: the call goes on with what the hook wrote
277
+ # before it raised, and the log says which hook and why.
278
+ def hooked(serving, call, hook, *args, event: nil)
279
+ serving.agent.run_hook(hook, *args)
280
+ rescue StandardError => e
281
+ call.log("hook.failed", { hook: hook.to_s, event:, error: e.class.to_s, message: e.message }.compact)
282
+ warn("pinecall: #{call.id} #{hook} raised and the call goes on without it: #{e.message}")
283
+ nil
284
+ end
285
+
232
286
  def agent_class(serving) = serving.agent.class
233
287
 
234
- # `call.log` data must be an object; wrap scalars under `value`.
288
+ # `call.log` data must be an object: a `Data` or a `Struct` is its fields, other values go under `value`.
235
289
  def as_object(data)
236
- return {} if data.nil?
290
+ fields = Wire::Codec.plain(data)
291
+ return {} if fields.nil?
237
292
 
238
- data.is_a?(Hash) ? data : { value: data }
293
+ fields.is_a?(Hash) ? fields : { value: fields }
239
294
  end
240
295
  end
241
296
  end
@@ -9,17 +9,26 @@ module Pinecall
9
9
  # State is reduced from received entries; each verb is one wire command. No LiveKit access by
10
10
  # design: a missing capability should become a new command of the wire.
11
11
  class CallWorld
12
- attr_reader :id, :contact, :from, :channel, :medium, :room, :turns, :today, :claimed
12
+ attr_reader :id, :contact, :from, :to, :channel, :direction, :caller, :metadata, :medium, :room, :turns, :today,
13
+ :claimed
13
14
 
14
15
  # `send` puts one command on the wire for this call; `searching` asks the gateway to search for
15
16
  # it. Both are supplied by the bridge. `today` is the day the call opened, YYYY-MM-DD. `medium` is
16
17
  # spoken (`voice`) or written (`text`); a gateway that does not say gets it from the channel.
17
- def initialize(id:, contact: nil, from: nil, channel: nil, medium: nil, today: Time.now.strftime("%Y-%m-%d"),
18
- claimed: nil, searching: nil, &send)
18
+ # `to` is the number called, or the agent a visitor reached; `direction` is `inbound` when the public
19
+ # reached the agent and `outbound` when it dialled; `caller` is who the platform knows is on the line
20
+ # (`id`, `name`, `phone`, `email`), nil when nobody is named; `metadata` is what the call was opened with,
21
+ # such as a token's, `{}` when none.
22
+ def initialize(id:, contact: nil, from: nil, to: nil, channel: nil, direction: nil, caller: nil, metadata: nil,
23
+ medium: nil, today: Time.now.strftime("%Y-%m-%d"), claimed: nil, searching: nil, &send)
19
24
  @id = id
20
25
  @contact = contact
21
26
  @from = from
27
+ @to = to
22
28
  @channel = channel
29
+ @direction = direction
30
+ @caller = caller
31
+ @metadata = metadata || {}
23
32
  @medium = (medium || (channel.to_s == "whatsapp" ? "text" : "voice")).to_s
24
33
  @today = today
25
34
  @claimed = claimed
@@ -29,15 +38,11 @@ module Pinecall
29
38
  @turns = []
30
39
  @waiting = Waiting.new
31
40
  @cause = nil
32
- @events = 0
33
41
  end
34
42
 
35
- # The external event being handled, if any; logged as the cause of state changes.
43
+ # The external event being handled, if any (`name` and the entry's `seq`); the cause of the writes it makes.
36
44
  attr_accessor :cause
37
45
 
38
- # Next per-call event sequence number (not the wire's seq).
39
- def numbered = @events += 1
40
-
41
46
  # ── commands ─────────────────────────────────────────────────────────────
42
47
 
43
48
  # Speak `text` verbatim now. Returns true once the turn arrives, false after `LANDS_WITHIN_S`.
@@ -110,7 +115,8 @@ module Pinecall
110
115
 
111
116
  # Append an application entry to the call's log.
112
117
  def log(name, data = {})
113
- @send.call("call.log", { name: name.to_s, data: data.is_a?(Hash) ? data : { value: data } })
118
+ fields = Wire::Codec.plain(data)
119
+ @send.call("call.log", { name: name.to_s, data: fields.is_a?(Hash) ? fields : { value: fields } })
114
120
  end
115
121
 
116
122
  # The caller asked never to be called again: their number joins the org's do-not-call list, and no
@@ -20,9 +20,11 @@ module Pinecall
20
20
  @client = client
21
21
  @routes = options[:routes] || []
22
22
  @takes_unclaimed = options.fetch(:takes_unclaimed, true)
23
+ # Sends `call.done` once a call's end hook ran: the gateway's seal waits for the hook's lines.
24
+ @says_done = options.fetch(:says_done, false)
23
25
  @tools = {}
24
26
  @waiting = []
25
- @config = options.reject { |key, _| %i[routes tools takes_unclaimed].include?(key) }
27
+ @config = options.reject { |key, _| %i[routes tools takes_unclaimed says_done].include?(key) }
26
28
  @app = nil
27
29
  @running = []
28
30
  @dev = nil
@@ -46,7 +48,8 @@ module Pinecall
46
48
 
47
49
  # Replace the tools; sent with the next `configure`.
48
50
  #
49
- # A tool is `{ **spec, run: ->(arguments, call) { … } }`.
51
+ # A tool is `{ **spec, run: ->(arguments, call, call_id) { … } }`: `call_id` is the model's id for the
52
+ # call, which a state write it makes names as its cause.
50
53
  def declare(tools)
51
54
  @tools = tools.to_h { |tool| [tool[:name].to_s, tool] }
52
55
  @config = @config.merge(tools: tools.map { |tool| tool.reject { |key, _| key == :run } })
@@ -61,8 +64,8 @@ module Pinecall
61
64
  # Register the slug and send the config. Runs on every reconnect.
62
65
  def open
63
66
  registered = ask("agent.register", lands_as: "agent.registered", data: {
64
- routes: @routes, sdk: @client.sdk, host: Socket.gethostname,
65
- takes_unclaimed: @takes_unclaimed
67
+ routes: @routes, sdk: @client.sdk, host: @client.host,
68
+ takes_unclaimed: @takes_unclaimed, **(@says_done ? { says_done: true } : {})
66
69
  })
67
70
  # Each connection gets a new app id.
68
71
  @app = registered[:app]
@@ -117,12 +120,17 @@ module Pinecall
117
120
  def run_tool(event, call)
118
121
  call_id, name, arguments = event.data.values_at(:call_id, :name, :arguments)
119
122
  tool = @tools[name.to_s]
120
- return call.tool_result({ call_id:, name:, error: "this app declares no tool called #{name}" }) if tool.nil?
123
+ if tool.nil?
124
+ # The platform answers its own tool, and an answer from here would race it.
125
+ return if name == Wire::Enums::END_CALL
126
+
127
+ return call.tool_result({ call_id:, name:, error: "this app declares no tool called #{name}" })
128
+ end
121
129
 
122
130
  running = Thread.new do
123
131
  started = now
124
132
  begin
125
- output = tool[:run].call(arguments || {}, call)
133
+ output = tool[:run].call(arguments || {}, call, call_id)
126
134
  call.tool_result({ call_id:, name:, output:, duration_s: now - started })
127
135
  rescue StandardError => e
128
136
  # Report the exception to the model as the tool's `error`.
@@ -6,7 +6,7 @@ module Pinecall
6
6
  #
7
7
  # Commands are not awaited; their effects arrive later as log entries.
8
8
  class Call
9
- attr_reader :id, :today, :claimed, :run, :medium
9
+ attr_reader :id, :today, :claimed, :run, :medium, :direction, :metadata
10
10
  attr_accessor :status, :channel, :from, :to, :contact, :state
11
11
 
12
12
  def initialize(id, agent, opened_at)
@@ -17,6 +17,8 @@ module Pinecall
17
17
  @channel = nil
18
18
  # Spoken or written, as `call.started` says; nil before it, and from a gateway that does not say.
19
19
  @medium = nil
20
+ @direction = nil
21
+ @metadata = {}
20
22
  @from = nil
21
23
  @to = nil
22
24
  @contact = nil
@@ -48,11 +50,13 @@ module Pinecall
48
50
  # Set the tools currently visible to the model (a subset of the declared ones).
49
51
  def set_tools(tools) = command("tools.set", { tools: })
50
52
 
51
- # Send the full state; answered with `state.changed`.
52
- def set_state(state, changed = nil)
53
+ # Send the full state; answered with `state.changed`. `cause` says who wrote it, in the wire's
54
+ # `StateCause`: a tool, an outside event or a lifecycle hook.
55
+ def set_state(state, changed = nil, cause = nil)
53
56
  @state = state
54
57
  wire = { state: }
55
58
  wire[:changed] = Array(changed).map(&:to_s) unless changed.nil?
59
+ wire[:cause] = cause unless cause.nil?
56
60
  command("state.set", wire)
57
61
  end
58
62
 
@@ -67,6 +71,9 @@ module Pinecall
67
71
  # Append an application entry to the call's log, as a `custom` entry.
68
72
  def log(name, data = {}) = command("call.log", { name: name.to_s, data: })
69
73
 
74
+ # Say the end hook ran and what it wrote was sent: the gateway seals the call on this word.
75
+ def done = command("call.done", {})
76
+
70
77
  # Send any call-scoped command of the wire.
71
78
  def command(type, data) = @agent.command(type, @id, data)
72
79
 
@@ -104,6 +111,8 @@ module Pinecall
104
111
  def started(line)
105
112
  the_line(line, "active")
106
113
  @medium = line[:medium]
114
+ @direction = line[:direction]
115
+ @metadata = line[:metadata] || {}
107
116
  # A run's call says its day, which a golden may have pinned; a person's runs on the clock's.
108
117
  @today = line[:today] if line[:today]
109
118
  end
@@ -32,6 +32,9 @@ module Pinecall
32
32
 
33
33
  def open? = @open
34
34
 
35
+ # Never the key, nor the driver, which holds it in its headers.
36
+ def inspect = "#<#{self.class} #{@url} #{@open ? "open" : "closed"}>"
37
+
35
38
  # Connect and return once `on_open` has run; reconnects are handled internally afterwards.
36
39
  def start
37
40
  @closed = false
@@ -70,11 +73,12 @@ module Pinecall
70
73
  nil
71
74
  end
72
75
 
73
- # Called by websocket-driver.
76
+ # Called by websocket-driver. Once this side closed the socket, what the driver still writes (the
77
+ # answer to the gateway's close frame) has nowhere to go, and that is not a failure.
74
78
  def write(bytes)
75
79
  @socket.write(bytes)
76
80
  rescue IOError, SystemCallError => e
77
- @handlers.on_error.call(e)
81
+ @handlers.on_error.call(e) unless @closed
78
82
  end
79
83
 
80
84
  private
@@ -44,6 +44,8 @@ module Pinecall
44
44
  Drained = Data.define(:handed, :parked, :tools, :finished)
45
45
 
46
46
  attr_reader :url, :api_key, :env
47
+ # The machine it says it runs on: a hosted app's is the name its runner gave it.
48
+ attr_accessor :host
47
49
 
48
50
  def initialize(url:, api_key:, env: nil, ping_every: Connection::PINGS_EVERY_S, backoff: {})
49
51
  raise ArgumentError, "Pinecall::Client.new(url:, api_key:): one of the two was not given" if url.nil? || api_key.nil?
@@ -51,10 +53,12 @@ module Pinecall
51
53
  @url = url
52
54
  @api_key = api_key
53
55
  @env = env
56
+ @host = Socket.gethostname
54
57
  @agents = {}
55
58
  @errors = []
56
59
  @entries = []
57
60
  @stops = []
61
+ @kills = []
58
62
  @connects = []
59
63
  @listeners = Listeners.new { |error| on_error(error) }
60
64
  @connection = Connection.new(
@@ -73,6 +77,9 @@ module Pinecall
73
77
  @agents[slug] = Agent.new(slug, options, self)
74
78
  end
75
79
 
80
+ # What a log line or an exception prints of the client: its door and its agents, never the key.
81
+ def inspect = "#<#{self.class} #{@url} env=#{@env.inspect} agents=#{@agents.keys.inspect}>"
82
+
76
83
  # Declared agents by slug.
77
84
  def agents = @agents.dup
78
85
 
@@ -120,6 +127,13 @@ module Pinecall
120
127
  -> { @stops.delete(listener) }
121
128
  end
122
129
 
130
+ # Called when an admin lets go of one of this client's agents: the listener takes its slug and the
131
+ # gateway's sentence. The socket stays open for the others. Returns a lambda that unsubscribes.
132
+ def on_killed(&listener)
133
+ @kills << listener
134
+ -> { @kills.delete(listener) }
135
+ end
136
+
123
137
  # True while the socket is up.
124
138
  def connected? = @connection.open?
125
139
 
@@ -177,7 +191,7 @@ module Pinecall
177
191
  return if agent.nil?
178
192
 
179
193
  guarded { agent.take(entry) }
180
- @agents.delete(entry.agent) if killed?(entry)
194
+ killed(entry) if killed?(entry)
181
195
  end
182
196
 
183
197
  # An `error` coded `stopped` for no agent: a member of the org pressed Stop.
@@ -185,6 +199,12 @@ module Pinecall
185
199
 
186
200
  def killed?(entry) = entry.type == "error" && entry.data[:code] == KILLED
187
201
 
202
+ # The agent is no longer declared, so a reconnect leaves it out; then whoever asked to know.
203
+ def killed(entry)
204
+ @agents.delete(entry.agent)
205
+ @kills.each { |listener| guarded { listener.call(entry.agent, entry.data[:message]) } }
206
+ end
207
+
188
208
  # With no stop listener the stop is reported as an error, so it is never silent.
189
209
  # Every agent registered again, then whoever asked to know.
190
210
  def opened
@@ -8,21 +8,24 @@ module Pinecall
8
8
  class Held
9
9
  attr_reader :client, :mounted
10
10
 
11
- def initialize(client, mounted)
11
+ def initialize(client, mounted, err: $stderr)
12
12
  @client = client
13
13
  @mounted = mounted
14
+ @err = err
14
15
  @stopped = Thread::Queue.new
15
16
  @lock = Mutex.new
16
17
  @drained = nil
17
18
  end
18
19
 
19
- # Drain every agent, then close the socket; asked twice, the first answer. Returns the drain.
20
+ # Drain every agent, close the socket and say on stderr where the calls went; asked twice, the
21
+ # first answer. Returns the drain.
20
22
  def stop
21
23
  @lock.synchronize do
22
24
  return @drained unless @drained.nil?
23
25
 
24
26
  @drained = @client.drain
25
27
  close
28
+ @err.puts(Serve.drain_line(@drained))
26
29
  @drained
27
30
  end
28
31
  end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What a served process prints of its agent's entries: the values the agent declared private, masked as the call's log masks them.
4
+
5
+ module Pinecall
6
+ module Serve
7
+ # One agent's private names and, as its tool calls pass, the private values they carried.
8
+ class Masking
9
+ MASK = "***"
10
+
11
+ # Shorter than this a value is never learned as a secret: "7", "ok" or "Al" cannot be told from a
12
+ # word, and hunting it in what else is written would mask every such digit or word. The argument is
13
+ # still masked by its name; only its value is not looked for.
14
+ LEAST_SECRET_LENGTH = 3
15
+
16
+ # What the agent wrote down, by entry: the fields of its data a private value may be in. What was
17
+ # said is the caller's and the model's words, and never masked.
18
+ WRITTEN_DOWN = {
19
+ "tool.call" => %i[arguments],
20
+ "tool.result" => %i[output error summary],
21
+ "state.changed" => %i[state],
22
+ "call.attached" => %i[state],
23
+ "custom" => %i[data]
24
+ }.freeze
25
+
26
+ # `declared` is the agent's wire config: the arguments its tools list under `pii`, and the state
27
+ # fields declared `pii`.
28
+ def initialize(declared)
29
+ @arguments = (declared[:tools] || []).to_h { |tool| [tool[:name].to_s, Array(tool[:pii]).map(&:to_sym)] }
30
+ @fields = (declared[:state_fields] || []).filter_map { |field| field[:name].to_sym if field[:visibility].to_s == "pii" }
31
+ @values = Set.new
32
+ @pattern = nil
33
+ end
34
+
35
+ # The entry with every private value masked: by name where the agent named it, by value wherever
36
+ # else it was written down. An entry with nothing private to hide is the entry itself.
37
+ def entry(entry)
38
+ fields = WRITTEN_DOWN[entry.type]
39
+ return entry if fields.nil?
40
+
41
+ named = @arguments.fetch(entry.data[:name].to_s, [])
42
+ return entry unless @values.any? || @fields.any? || (entry.type == "tool.call" && named.any?)
43
+
44
+ learn(named, entry.data) if entry.type == "tool.call"
45
+ data = entry.data.dup
46
+ fields.each { |field| data[field] = masked(data[field], names_in(entry, field, named)) if data.key?(field) }
47
+ entry.with(data:)
48
+ end
49
+
50
+ private
51
+
52
+ def names_in(entry, field, named)
53
+ return named if entry.type == "tool.call" && field == :arguments
54
+
55
+ field == :state ? @fields : []
56
+ end
57
+
58
+ def learn(named, data)
59
+ given = data[:arguments]
60
+ return unless given.is_a?(Hash)
61
+
62
+ known = @values.size
63
+ named.each { |name| @values.merge(leaves(given[name])) }
64
+ @pattern = whole_words if @values.size != known
65
+ end
66
+
67
+ # A hash is masked member by member; any other value is masked of the secrets alone.
68
+ def masked(value, names)
69
+ return scrubbed(value) unless value.is_a?(Hash)
70
+
71
+ value.to_h { |name, member| [name, names.include?(name.to_sym) ? MASK : scrubbed(member)] }
72
+ end
73
+
74
+ # A leaf that is a private value goes whole; a string that holds one as a whole word has it
75
+ # replaced, so the rest of what it said stays readable.
76
+ def scrubbed(value)
77
+ return value if @pattern.nil?
78
+
79
+ case value
80
+ when String then value.gsub(@pattern, MASK)
81
+ when Numeric then @values.include?(value.to_s) ? MASK : value
82
+ when Array then value.map { |item| scrubbed(item) }
83
+ when Hash then value.to_h { |name, member| [name, scrubbed(member)] }
84
+ else value
85
+ end
86
+ end
87
+
88
+ # A value is a secret where it stands as a whole word: `Ana` is no part of `Banana` or `Anabel`, nor
89
+ # `7` of `1700`. The longest comes first, so a value that holds another is masked whole.
90
+ def whole_words
91
+ ordered = @values.sort_by { |secret| -secret.length }
92
+ Regexp.new("(?<![[:alnum:]_])(?:#{ordered.map { |secret| Regexp.escape(secret) }.join("|")})(?![[:alnum:]_])")
93
+ end
94
+
95
+ # Every string and number inside a value, as text; a blank is no secret, it would mask everything.
96
+ def leaves(value)
97
+ case value
98
+ when String then secret_of(value.strip)
99
+ when Numeric then secret_of(value.to_s)
100
+ when Array then value.flat_map { |item| leaves(item) }
101
+ when Hash then value.values.flat_map { |member| leaves(member) }
102
+ else []
103
+ end
104
+ end
105
+
106
+ def secret_of(text) = text.length >= LEAST_SECRET_LENGTH ? [text] : []
107
+ end
108
+ end
109
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "serve/loading"
4
4
  require_relative "serve/held"
5
+ require_relative "serve/masking"
5
6
  require_relative "serve/viewing"
6
7
 
7
8
  module Pinecall
@@ -40,18 +41,18 @@ module Pinecall
40
41
  def start(flags, out:, err:, env:, input:, signals:)
41
42
  client = client_from(env, prod: flags.prod)
42
43
  classes = flags.served.map { |served| [served, Loading.load_served(served)] }
43
- said(client, out:, err:, events: flags.events)
44
44
  mounted = classes.map do |served, klass|
45
45
  held = Pinecall.mount(klass, client:, slug: served.slug, takes_unclaimed: !flags.console)
46
46
  held.agent.on_dev { |verb, data| Viewing.answer(klass, served.slug, verb, data) }
47
47
  held
48
48
  end
49
- held = Held.new(client, mounted)
49
+ said(client, out:, err:, events: flags.events, maskings: mounted.to_h { |one| [one.slug, Masking.new(one.options)] })
50
+ held = Held.new(client, mounted, err:)
50
51
  asked = signals || trapped
51
52
  client.on_stopped { |why| err.puts(why) || asked.push(:stopped) }
52
53
  client.connect
53
54
  Thread.new { ended(input, asked) }
54
- leave(held, asked, err)
55
+ leave(held, asked)
55
56
  end
56
57
 
57
58
  # Load the class, open it in the state the pairs name, and print its prompt. No gateway, no key.
@@ -115,21 +116,27 @@ module Pinecall
115
116
  world = nil if world.to_s.empty?
116
117
  raise CannotServe, "PINECALL_ENV is sandbox or production, not #{world}" unless world.nil? || WORLDS.include?(world)
117
118
 
118
- Client.new(url:, api_key: key, env: world)
119
+ client = Client.new(url:, api_key: key, env: world)
120
+ # The name a hosted app's runner gave it: the gateway knows the release by it.
121
+ client.host = env["PINECALL_HOST"] unless env["PINECALL_HOST"].to_s.empty?
122
+ client
119
123
  end
120
124
 
121
125
  # Listening starts before connect, so `agent.registered` is the first line of a pipe. A pipe's
122
126
  # $stdout is buffered: each line is flushed, or the CLI waiting on it sees it only at exit.
123
- def said(client, out:, err:, events:)
127
+ def said(client, out:, err:, events:, maskings:)
124
128
  client.on_entries do |entry|
125
129
  if events
126
- out.puts(JSON.generate({ type: entry.type, agent: entry.agent, call: entry.call, data: entry.data }))
130
+ # What it prints is what the call's log keeps: a value the agent declared private never reaches a terminal.
131
+ shown = maskings[entry.agent]&.entry(entry) || entry
132
+ out.puts(JSON.generate({ type: shown.type, agent: shown.agent, call: shown.call, data: shown.data }))
127
133
  elsif entry.type == "agent.registered"
128
134
  out.puts("#{entry.agent} · answering as #{entry.data[:app]}")
129
135
  end
130
136
  out.flush
131
137
  end
132
138
  client.on_errors { |error| err.puts("#{error.class}: #{error.message}") }
139
+ client.on_killed { |slug, why| err.puts("#{slug} ✗ #{why}") }
133
140
  end
134
141
 
135
142
  # A trap only pushes: the main thread does the leaving, as a trap may not take a lock.
@@ -150,7 +157,7 @@ module Pinecall
150
157
 
151
158
  # The first reason drains (unless the org already stopped it); only a second signal closes at
152
159
  # once. The end of stdin is not one: the CLI passes a signal on and closes the pipe together.
153
- def leave(held, asked, err)
160
+ def leave(held, asked)
154
161
  if asked.pop == :stopped
155
162
  held.close
156
163
  return 0
@@ -161,8 +168,7 @@ module Pinecall
161
168
  nil until asked.pop == :signalled
162
169
  outcome.push(:again)
163
170
  end
164
- drained = outcome.pop
165
- drained == :again ? held.close : err.puts(drain_line(drained))
171
+ held.close if outcome.pop == :again
166
172
  0
167
173
  end
168
174
  end
@@ -104,6 +104,9 @@ module Pinecall
104
104
 
105
105
  def sdk = "pinecall-ruby-testing/#{VERSION}"
106
106
 
107
+ # Same as `Pinecall::Client#host`: the machine it says it runs on.
108
+ def host = Socket.gethostname
109
+
107
110
  # Same signature as `Pinecall::Client#agent`.
108
111
  def agent(slug, **options)
109
112
  @agents[slug] = Client::Agent.new(slug, options, self)
@@ -128,13 +131,14 @@ module Pinecall
128
131
  attr_reader :errors
129
132
 
130
133
  # Start a call and return its `Fake` handle; `state:` is the state its opener asked for,
131
- # `today:` the day a golden pinned (YYYY-MM-DD).
132
- def call_started(id: nil, channel: "web", from: "+34600000000", to: "+34910000000", caller: nil, state: nil,
133
- today: nil)
134
+ # `today:` the day a golden pinned (YYYY-MM-DD), `metadata:` what a token opened it with.
135
+ def call_started(id: nil, channel: "web", direction: "inbound", from: "+34600000000", to: "+34910000000",
136
+ caller: nil, state: nil, today: nil, metadata: nil)
134
137
  id ||= "CA_#{next_seq}"
135
- started = { channel:, direction: "inbound", from:, to:, caller:, started_at: Time.now.to_f }
138
+ started = { channel:, direction:, from:, to:, caller:, started_at: Time.now.to_f }
136
139
  started = started.merge(state:) unless state.nil?
137
140
  started = started.merge(today:) unless today.nil?
141
+ started = started.merge(metadata:) unless metadata.nil?
138
142
  deliver("call.started", started, call: id)
139
143
  settle
140
144
  Fake.new(self, id)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Pinecall
4
4
  # Set by the maintainer at release time.
5
- VERSION = "0.11.14"
5
+ VERSION = "0.11.16"
6
6
  end
@@ -12,8 +12,8 @@ module Pinecall
12
12
  # `error` naming the command's id.
13
13
  Command = Data.define(:type, :agent, :call, :id, :data)
14
14
 
15
- # An entry read as what it means: the wire type, and the payload that type names.
16
- Event = Data.define(:type, :data)
15
+ # An entry read as what it means: the wire type, the payload that type names, and the entry's seq.
16
+ Event = Data.define(:type, :data, :seq)
17
17
 
18
18
  # JSON encoding and decoding. Keys are symbols in Ruby and strings on the wire; both are
19
19
  # snake_case, so unlike TypeScript there is no key renaming.
@@ -33,7 +33,7 @@ module Pinecall
33
33
  raise WireError, "unknown event type: #{entry.type}" if shape.nil?
34
34
 
35
35
  Validate.call!(shape, entry.data, where: entry.type)
36
- Event.new(type: entry.type, data: entry.data)
36
+ Event.new(type: entry.type, data: entry.data, seq: entry.seq)
37
37
  end
38
38
 
39
39
  # Build a command frame, validating its data and then the whole envelope. Validating here
@@ -42,6 +42,7 @@ module Pinecall
42
42
  shape = Registry::COMMANDS[type]
43
43
  raise WireError, "unknown command type: #{type}" if shape.nil?
44
44
 
45
+ data = plain(data)
45
46
  Validate.call!(shape, data, where: type)
46
47
  frame = { type:, agent:, call:, data: }
47
48
  frame[:id] = id unless id.nil?
@@ -49,6 +50,17 @@ module Pinecall
49
50
  Command.new(type:, agent:, call:, id:, data:)
50
51
  end
51
52
 
53
+ # A Ruby value as the JSON the wire carries: a `Data` or a `Struct` is its fields, wherever it sits in
54
+ # an array or a hash, since `JSON.generate` would send its `inspect`.
55
+ def plain(value)
56
+ case value
57
+ when Data, Struct then plain(value.to_h)
58
+ when Hash then value.to_h { |key, inner| [key, plain(inner)] }
59
+ when Array then value.map { |inner| plain(inner) }
60
+ else value
61
+ end
62
+ end
63
+
52
64
  # A frame as JSON text. A command without an id omits the key (the schema refuses null);
53
65
  # `call` stays null, since a command about the agent itself belongs to no call.
54
66
  def encode(frame)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Every closed list of the wire this gem reads, as a frozen array.
3
+ # Every closed list of the wire this gem reads, as a frozen array, and the name the platform keeps for itself.
4
4
 
5
5
  module Pinecall
6
6
  module Wire
@@ -8,6 +8,9 @@ module Pinecall
8
8
  # The door the public came through: a phone call over SIP, the browser widget over WebRTC,
9
9
  # or WhatsApp text.
10
10
  CHANNEL = %w[phone web whatsapp].freeze
11
+ # The tool the platform puts in front of a model that may hang up. Its tool.call and its
12
+ # tool.result are both the platform's, so an app answers none.
13
+ END_CALL = "end_call"
11
14
  # Inbound: the public reached the agent. Outbound: the agent reached out (a dial).
12
15
  DIRECTION = %w[inbound outbound].freeze
13
16
  # How a call is had: spoken in a room, or written. A web call can be either.
@@ -101,6 +101,7 @@ module Pinecall
101
101
  "call.callback" => "CallCallback",
102
102
  "call.claim" => "CallClaim",
103
103
  "call.dial" => "CallDial",
104
+ "call.done" => "CallDone",
104
105
  "call.dtmf" => "CallDtmf",
105
106
  "call.event" => "CallEvent",
106
107
  "call.hangup" => "CallHangup",
@@ -129,7 +130,7 @@ module Pinecall
129
130
  EVENT_TYPES = %w[agent.configured agent.detached agent.draining agent.registered agent.state agent.transcript app.failed 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 evals.spent event.received fleet.full judge.broke 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 release.changed room.opened room.sent run.changed spend.unusual state.changed supervisor.ended supervisor.released supervisor.said supervisor.took_over supervisor.transferred supervisor.whispered tool.call tool.failed tool.ran tool.result tools.changed track.published track.unpublished turn.agent turn.user user.state user.transcript vendor.switched worker.changed].freeze
130
131
 
131
132
  # Every command an app may send.
132
- 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
133
+ COMMAND_TYPES = %w[agent.configure agent.drain agent.register agent.reply agent.say call.attention call.callback call.claim call.dial call.done 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
133
134
 
134
135
  # Entries a store may drop and a slow reader may miss without harm: the entry's
135
136
  # own ephemeral flag defaults to this.
@@ -149,6 +150,7 @@ module Pinecall
149
150
  "call.callback" => %w[callback.requested],
150
151
  "call.claim" => %w[call.claimed],
151
152
  "call.dial" => %w[call.dialing],
153
+ "call.done" => [],
152
154
  "call.dtmf" => [],
153
155
  "call.event" => %w[event.received],
154
156
  "call.hangup" => %w[call.ended],
@@ -21,7 +21,7 @@ module Pinecall
21
21
  "ModelUsage" => { on: :type, members: %w[LLMModelUsage TTSModelUsage STTModelUsage InterruptionModelUsage EOTModelUsage] }.freeze,
22
22
  "Turn" => { on: :role, members: %w[UserTurn AgentTurn] }.freeze,
23
23
  "Verb" => { on: :verb, members: %w[SayVerb WhisperVerb TakeoverVerb ReleaseVerb TransferVerb EndVerb] }.freeze,
24
- "StateCause" => { on: :kind, members: %w[StateCauseTool StateCauseEvent] }.freeze
24
+ "StateCause" => { on: :kind, members: %w[StateCauseTool StateCauseEvent StateCauseHook] }.freeze
25
25
  }.freeze
26
26
 
27
27
  # A map is keyed by names the app chose; every value is the one shape given here.
@@ -100,7 +100,8 @@ module Pinecall
100
100
  worker: { kind: :str, null: true },
101
101
  medium: { kind: :ref, ref: "Medium", null: true },
102
102
  state: { kind: :json },
103
- today: { kind: :str, null: true }
103
+ today: { kind: :str, null: true },
104
+ metadata: { kind: :json }
104
105
  }.freeze,
105
106
  "CallSummary" => {
106
107
  reason: { kind: :ref, ref: "EndReason", required: true },
@@ -15,7 +15,8 @@ module Pinecall
15
15
  sdk: { kind: :str },
16
16
  host: { kind: :str },
17
17
  takes_unclaimed: { kind: :bool, default: true },
18
- answers_dev: { kind: :bool, default: false }
18
+ answers_dev: { kind: :bool, default: false },
19
+ says_done: { kind: :bool, default: false }
19
20
  }.freeze,
20
21
  "AgentReply" => {
21
22
  instructions: { kind: :str, required: true },
@@ -50,6 +51,7 @@ module Pinecall
50
51
  name: { kind: :str, required: true },
51
52
  data: { kind: :json, required: true }
52
53
  }.freeze,
54
+ "CallDone" => {}.freeze,
53
55
  "CallHangup" => {
54
56
  reason: { kind: :str }
55
57
  }.freeze,
@@ -99,7 +101,8 @@ module Pinecall
99
101
  }.freeze,
100
102
  "StateSet" => {
101
103
  state: { kind: :json, required: true },
102
- changed: { kind: :list, items: { kind: :str } }
104
+ changed: { kind: :list, items: { kind: :str } },
105
+ cause: { kind: :ref, ref: "StateCause" }
103
106
  }.freeze,
104
107
  "SupervisorVerb" => {
105
108
  by: { kind: :ref, ref: "Supervisor", required: true },
@@ -337,6 +337,10 @@ module Pinecall
337
337
  name: { kind: :str, required: true },
338
338
  seq: { kind: :int, required: true }
339
339
  }.freeze,
340
+ "StateCauseHook" => {
341
+ kind: { kind: :const, const: "hook", required: true },
342
+ hook: { kind: :enum, values: %w[on_call on_memory], required: true }
343
+ }.freeze,
340
344
  }.freeze
341
345
  end
342
346
  end
data/sig/pinecall.rbs CHANGED
@@ -125,7 +125,7 @@ module Pinecall
125
125
  def doc: () -> String?
126
126
  def tools: () -> Array[spec]
127
127
  def visible_tools: () -> Array[spec]
128
- def run_tool: (String | Symbol, ?Hash[Symbol, untyped]) -> untyped
128
+ def run_tool: (String | Symbol, ?Hash[Symbol, untyped], ?String?) -> untyped
129
129
  def snapshot: () -> state
130
130
  def restore: (state) -> Agent
131
131
  def start_in: (state) -> Agent
@@ -156,8 +156,10 @@ module Pinecall
156
156
 
157
157
  module Author
158
158
  KEY: Symbol
159
+ CALL: Symbol
159
160
  def self?.current: () -> String?
160
- def self?.with: [T] (String) { () -> T } -> T
161
+ def self?.call_id: () -> String?
162
+ def self?.with: [T] (String, ?String?) { () -> T } -> T
161
163
  end
162
164
 
163
165
  module Doc
@@ -221,14 +223,18 @@ module Pinecall
221
223
  attr_reader id: String
222
224
  attr_reader contact: String?
223
225
  attr_reader from: String?
226
+ attr_reader to: String?
224
227
  attr_reader channel: String?
228
+ attr_reader direction: String?
229
+ attr_reader caller: Hash[Symbol, untyped]?
230
+ attr_reader metadata: Hash[Symbol, untyped]
225
231
  attr_reader medium: String
226
232
  attr_reader today: String
227
233
  attr_reader claimed: String?
228
234
  attr_accessor cause: Hash[Symbol, untyped]?
229
- def initialize: (id: String, ?contact: String?, ?from: String?, ?channel: String?, ?medium: String?, ?today: String, ?claimed: String?,
230
- ?searching: (^(String, Integer?) -> Array[Hash[Symbol, untyped]])?) { (String, Hash[Symbol, untyped]) -> untyped } -> void
231
- def numbered: () -> Integer
235
+ def initialize: (id: String, ?contact: String?, ?from: String?, ?to: String?, ?channel: String?, ?direction: String?,
236
+ ?caller: Hash[Symbol, untyped]?, ?metadata: Hash[Symbol, untyped]?, ?medium: String?, ?today: String,
237
+ ?claimed: String?, ?searching: (^(String, Integer?) -> Array[Hash[Symbol, untyped]])?) { (String, Hash[Symbol, untyped]) -> untyped } -> void
232
238
  def say: (String, **untyped) -> bool
233
239
  def reply: (String, **untyped) -> bool
234
240
  def search: (String, ?k: Integer?) -> Array[Found]
@@ -266,10 +272,12 @@ module Pinecall
266
272
  end
267
273
 
268
274
  attr_reader env: String?
275
+ attr_accessor host: String
269
276
  def initialize: (url: String, api_key: String, ?env: String?, ?ping_every: Numeric, ?backoff: Hash[Symbol, Numeric]) -> void
270
277
  def drain: (?answer_s: Numeric, ?tools_s: Numeric) -> Drained
271
278
  def on_entries: () { (untyped) -> void } -> ^() -> void
272
279
  def on_stopped: () { (String) -> void } -> ^() -> void
280
+ def on_killed: () { (String, String) -> void } -> ^() -> void
273
281
  def sdk: () -> String
274
282
  def agent: (String, **untyped) -> Client::Agent
275
283
  def agents: () -> Hash[String, Client::Agent]
@@ -313,6 +321,8 @@ module Pinecall
313
321
  attr_reader claimed: String?
314
322
  attr_reader run: String?
315
323
  attr_reader medium: String?
324
+ attr_reader direction: String?
325
+ attr_reader metadata: Hash[Symbol, untyped]
316
326
  attr_reader today: String
317
327
  attr_accessor status: String
318
328
  attr_accessor channel: String?
@@ -324,11 +334,12 @@ module Pinecall
324
334
  def reply: (String, **untyped) -> untyped
325
335
  def set_prompt: (String | Symbol, String) -> untyped
326
336
  def set_tools: (Array[Hash[Symbol, untyped]]) -> untyped
327
- def set_state: (Hash[Symbol, untyped], ?Array[Symbol | String]?) -> untyped
337
+ def set_state: (Hash[Symbol, untyped], ?Array[Symbol | String]?, ?Hash[Symbol, untyped]?) -> untyped
328
338
  def tool_result: (Hash[Symbol, untyped]) -> untyped
329
339
  def event: (String, Hash[Symbol, untyped]) -> untyped
330
340
  def hangup: (?String?) -> untyped
331
341
  def log: (String | Symbol, ?Hash[Symbol, untyped]) -> untyped
342
+ def done: () -> untyped
332
343
  def command: (String, Hash[Symbol, untyped]) -> untyped
333
344
  def take: (untyped) -> void
334
345
  def on: (String) { (Hash[Symbol, untyped], Call) -> void } -> ^() -> void
@@ -380,10 +391,18 @@ module Pinecall
380
391
  def initialize: (file: String, slug: String) -> void
381
392
  end
382
393
 
394
+ class Masking
395
+ MASK: String
396
+ LEAST_SECRET_LENGTH: Integer
397
+ WRITTEN_DOWN: Hash[String, Array[Symbol]]
398
+ def initialize: (Hash[Symbol, untyped]) -> void
399
+ def entry: (untyped) -> untyped
400
+ end
401
+
383
402
  class Held
384
403
  attr_reader client: Client
385
404
  attr_reader mounted: Array[Bridge::Mounted]
386
- def initialize: (Client, Array[Bridge::Mounted]) -> void
405
+ def initialize: (Client, Array[Bridge::Mounted], ?err: IO) -> void
387
406
  def stop: () -> Client::Drained
388
407
  def close: () -> untyped
389
408
  def join: () -> untyped
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pinecall
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.14
4
+ version: 0.11.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pinecall
@@ -66,6 +66,7 @@ files:
66
66
  - lib/pinecall/serve.rb
67
67
  - lib/pinecall/serve/held.rb
68
68
  - lib/pinecall/serve/loading.rb
69
+ - lib/pinecall/serve/masking.rb
69
70
  - lib/pinecall/serve/viewing.rb
70
71
  - lib/pinecall/testing.rb
71
72
  - lib/pinecall/version.rb