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,340 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The shapes the events, commands and doors are made of.
4
+
5
+ module Pinecall
6
+ module Wire
7
+ module Shapes
8
+ PARTS = {
9
+ "Contact" => {
10
+ id: { kind: :str },
11
+ phone: { kind: :str },
12
+ name: { kind: :str },
13
+ email: { kind: :str },
14
+ external_id: { kind: :str }
15
+ }.freeze,
16
+ "Route" => {
17
+ channel: { kind: :ref, ref: "Channel", required: true },
18
+ number: { kind: :str, null: true, required: true },
19
+ label: { kind: :str }
20
+ }.freeze,
21
+ "Supervisor" => {
22
+ id: { kind: :str, required: true },
23
+ name: { kind: :str }
24
+ }.freeze,
25
+ "MemoryFact" => {
26
+ id: { kind: :str },
27
+ text: { kind: :str, required: true },
28
+ category: { kind: :str },
29
+ score: { kind: :float },
30
+ source: { kind: :str }
31
+ }.freeze,
32
+ "MemoryOp" => {
33
+ op: { kind: :enum, values: %w[recall remember forget], required: true },
34
+ contact: { kind: :str },
35
+ query: { kind: :str },
36
+ facts: { kind: :list, items: { kind: :ref, ref: "MemoryFact" }, required: true },
37
+ took_ms: { kind: :float, required: true }
38
+ }.freeze,
39
+ "DocSource" => {
40
+ id: { kind: :str, required: true },
41
+ base: { kind: :str },
42
+ path: { kind: :str, required: true },
43
+ heading: { kind: :str },
44
+ score: { kind: :float, required: true },
45
+ excerpt: { kind: :str }
46
+ }.freeze,
47
+ "CostRow" => {
48
+ provider: { kind: :str, required: true },
49
+ model: { kind: :str, required: true },
50
+ unit: { kind: :enum, values: %w[input_tokens cached_input_tokens cache_creation_tokens output_tokens characters audio_seconds requests session_seconds minutes], required: true },
51
+ quantity: { kind: :float, required: true },
52
+ unit_price_usd: { kind: :float, required: true },
53
+ usd: { kind: :float, required: true }
54
+ }.freeze,
55
+ "UnpricedRow" => {
56
+ provider: { kind: :str, required: true },
57
+ model: { kind: :str, required: true }
58
+ }.freeze,
59
+ "Cost" => {
60
+ usd: { kind: :float, required: true },
61
+ rows: { kind: :list, items: { kind: :ref, ref: "CostRow" }, required: true },
62
+ unpriced: { kind: :list, items: { kind: :ref, ref: "UnpricedRow" }, required: true }
63
+ }.freeze,
64
+ "Entry" => {
65
+ seq: { kind: :int, required: true },
66
+ ts: { kind: :float, required: true },
67
+ call: { kind: :str, null: true, required: true },
68
+ agent: { kind: :str, required: true },
69
+ type: { kind: :str, required: true },
70
+ ephemeral: { kind: :bool, required: true },
71
+ data: { kind: :json, required: true }
72
+ }.freeze,
73
+ "Command" => {
74
+ type: { kind: :str, required: true },
75
+ agent: { kind: :str, required: true },
76
+ call: { kind: :str, null: true, required: true },
77
+ id: { kind: :str },
78
+ data: { kind: :json, required: true }
79
+ }.freeze,
80
+ "Metadata" => {
81
+ model_name: { kind: :str, null: true },
82
+ model_provider: { kind: :str, null: true }
83
+ }.freeze,
84
+ "TurnMetadata" => {
85
+ model_name: { kind: :str },
86
+ model_provider: { kind: :str }
87
+ }.freeze,
88
+ "GoldenQuestion" => {
89
+ asks: { kind: :str, required: true },
90
+ expects: { kind: :str, required: true }
91
+ }.freeze,
92
+ "GoldenMiss" => {
93
+ asks: { kind: :str, required: true },
94
+ expects: { kind: :str, required: true },
95
+ found: { kind: :list, items: { kind: :str }, required: true }
96
+ }.freeze,
97
+ "KnowledgeBase" => {
98
+ base: { kind: :str, required: true },
99
+ chunks: { kind: :int, required: true },
100
+ model: { kind: :str, required: true },
101
+ pushed_at: { kind: :float, required: true }
102
+ }.freeze,
103
+ "ContactFact" => {
104
+ id: { kind: :str },
105
+ text: { kind: :str, required: true },
106
+ category: { kind: :str },
107
+ source: { kind: :str },
108
+ valid_from: { kind: :float, required: true },
109
+ invalidated_at: { kind: :float, null: true, required: true }
110
+ }.freeze,
111
+ "MemoryQuestion" => {
112
+ holds: { kind: :list, items: { kind: :str }, required: true },
113
+ asks: { kind: :str, required: true },
114
+ expects: { kind: :list, items: { kind: :str }, required: true }
115
+ }.freeze,
116
+ "MemoryMiss" => {
117
+ asks: { kind: :str, required: true },
118
+ missing: { kind: :list, items: { kind: :str }, required: true },
119
+ found: { kind: :list, items: { kind: :str }, required: true }
120
+ }.freeze,
121
+ "ExtractionExpected" => {
122
+ writes: { kind: :list, items: { kind: :str }, default: [] },
123
+ never: { kind: :list, items: { kind: :str }, default: [] },
124
+ never_says: { kind: :list, items: { kind: :str }, default: [] },
125
+ invalidates: { kind: :list, items: { kind: :str }, default: [] }
126
+ }.freeze,
127
+ "ExtractionGolden" => {
128
+ name: { kind: :str, required: true },
129
+ said: { kind: :list, items: { kind: :tuple, members: [{ kind: :str }, { kind: :str }] }, required: true },
130
+ holds: { kind: :list, items: { kind: :str }, default: [] },
131
+ plants: { kind: :list, items: { kind: :str }, default: [] },
132
+ channel: { kind: :ref, ref: "Channel", default: "phone" },
133
+ expect: { kind: :ref, ref: "ExtractionExpected", default: {} }
134
+ }.freeze,
135
+ "ExtractionBroke" => {
136
+ check: { kind: :str, required: true },
137
+ detail: { kind: :str, required: true }
138
+ }.freeze,
139
+ "ExtractionJudged" => {
140
+ name: { kind: :str, required: true },
141
+ held: { kind: :bool, required: true },
142
+ wrote: { kind: :list, items: { kind: :str }, default: [] },
143
+ refused: { kind: :list, items: { kind: :str }, default: [] },
144
+ broke: { kind: :list, items: { kind: :ref, ref: "ExtractionBroke" }, default: [] }
145
+ }.freeze,
146
+ "UserTurn" => {
147
+ role: { kind: :const, const: "user", required: true },
148
+ speech_id: { kind: :str, required: true },
149
+ item_id: { kind: :str },
150
+ text: { kind: :str, required: true },
151
+ language: { kind: :str },
152
+ transcript_confidence: { kind: :float },
153
+ metrics: { kind: :ref, ref: "UserTurnMetrics", required: true }
154
+ }.freeze,
155
+ "AgentTurn" => {
156
+ role: { kind: :const, const: "agent", required: true },
157
+ speech_id: { kind: :str, required: true },
158
+ item_id: { kind: :str },
159
+ text: { kind: :str, required: true },
160
+ interrupted: { kind: :bool, required: true },
161
+ metrics: { kind: :ref, ref: "AgentTurnMetrics", required: true }
162
+ }.freeze,
163
+ "ToolRun" => {
164
+ call_id: { kind: :str, required: true },
165
+ name: { kind: :str, required: true },
166
+ arguments: { kind: :json, required: true },
167
+ speech_id: { kind: :str },
168
+ status: { kind: :enum, values: %w[running done failed], required: true },
169
+ output: { kind: :any },
170
+ error: { kind: :str },
171
+ summary: { kind: :str },
172
+ duration_s: { kind: :float },
173
+ seq: { kind: :int, required: true }
174
+ }.freeze,
175
+ "PromptBlockState" => {
176
+ hash: { kind: :str, required: true },
177
+ chars: { kind: :int, required: true },
178
+ seq: { kind: :int, required: true }
179
+ }.freeze,
180
+ "Confirm" => {
181
+ tool: { kind: :str, required: true },
182
+ call_id: { kind: :str, required: true },
183
+ audience: { kind: :str, required: true },
184
+ phrase: { kind: :str, required: true },
185
+ status: { kind: :enum, values: %w[pending granted declined], required: true },
186
+ said: { kind: :str },
187
+ reason: { kind: :str }
188
+ }.freeze,
189
+ "Handoff" => {
190
+ active: { kind: :bool, required: true },
191
+ by: { kind: :ref, null: true, ref: "Supervisor", required: true }
192
+ }.freeze,
193
+ "TransferState" => {
194
+ to: { kind: :str, required: true },
195
+ mode: { kind: :ref, null: true, ref: "TransferMode" },
196
+ status: { kind: :enum, values: %w[requested done failed], required: true },
197
+ by: { kind: :enum, values: %w[agent supervisor], required: true }
198
+ }.freeze,
199
+ "AttentionState" => {
200
+ reason: { kind: :str, required: true },
201
+ wait_s: { kind: :float, required: true },
202
+ status: { kind: :enum, values: %w[open answered lapsed], required: true },
203
+ asked_at: { kind: :float, required: true },
204
+ by: { kind: :ref, null: true, ref: "Supervisor", required: true }
205
+ }.freeze,
206
+ "LiveTranscript" => {
207
+ user: { kind: :str, null: true, required: true },
208
+ agent: { kind: :str, null: true, required: true }
209
+ }.freeze,
210
+ "Gap" => {
211
+ from_seq: { kind: :int, required: true },
212
+ to_seq: { kind: :int, required: true }
213
+ }.freeze,
214
+ "LoggedError" => {
215
+ seq: { kind: :int, required: true },
216
+ code: { kind: :str, required: true },
217
+ message: { kind: :str, required: true }
218
+ }.freeze,
219
+ "Participant" => {
220
+ identity: { kind: :str, required: true },
221
+ kind: { kind: :ref, ref: "ParticipantKind", required: true },
222
+ name: { kind: :str },
223
+ joined_at: { kind: :float, required: true },
224
+ speaking: { kind: :bool, required: true },
225
+ attributes: { kind: :json, required: true }
226
+ }.freeze,
227
+ "Room" => {
228
+ name: { kind: :str, required: true },
229
+ sid: { kind: :str, required: true },
230
+ participants: { kind: :list, items: { kind: :ref, ref: "Participant" }, required: true },
231
+ caller: { kind: :str, null: true, required: true }
232
+ }.freeze,
233
+ "ReceivedEvent" => {
234
+ seq: { kind: :int, required: true },
235
+ name: { kind: :str, required: true },
236
+ source: { kind: :ref, ref: "EventSource", required: true },
237
+ identity: { kind: :str }
238
+ }.freeze,
239
+ "CustomNote" => {
240
+ seq: { kind: :int, required: true },
241
+ name: { kind: :str, required: true },
242
+ data: { kind: :json, required: true }
243
+ }.freeze,
244
+ "State" => {
245
+ seq: { kind: :int, required: true },
246
+ agent: { kind: :str, required: true },
247
+ call: { kind: :str, null: true, required: true },
248
+ status: { kind: :ref, ref: "CallStatus", required: true },
249
+ channel: { kind: :ref, null: true, ref: "Channel", required: true },
250
+ direction: { kind: :ref, null: true, ref: "Direction", required: true },
251
+ from: { kind: :str, null: true, required: true },
252
+ to: { kind: :str, null: true, required: true },
253
+ caller: { kind: :ref, null: true, ref: "Contact", required: true },
254
+ room: { kind: :ref, null: true, ref: "Room", required: true },
255
+ started_at: { kind: :float, null: true, required: true },
256
+ ended_at: { kind: :float, null: true, required: true },
257
+ end_reason: { kind: :ref, null: true, ref: "EndReason", required: true },
258
+ outcome: { kind: :str, null: true, required: true },
259
+ user_state: { kind: :ref, null: true, ref: "UserState", required: true },
260
+ agent_state: { kind: :ref, null: true, ref: "AgentState", required: true },
261
+ live: { kind: :ref, ref: "LiveTranscript", required: true },
262
+ turns: { kind: :list, items: { kind: :ref, ref: "Turn" }, required: true },
263
+ metrics: { kind: :ref, ref: "CollectedMetrics", required: true },
264
+ tools: { kind: :list, items: { kind: :ref, ref: "ToolRun" }, required: true },
265
+ app_state: { kind: :json, required: true },
266
+ events: { kind: :list, items: { kind: :ref, ref: "ReceivedEvent" }, required: true },
267
+ prompt: { kind: :ref, ref: "PromptState", required: true },
268
+ tools_visible: { kind: :list, items: { kind: :str }, required: true },
269
+ confirms: { kind: :list, items: { kind: :ref, ref: "Confirm" }, required: true },
270
+ memory: { kind: :list, items: { kind: :ref, ref: "MemoryOp" }, required: true },
271
+ sources: { kind: :list, items: { kind: :ref, ref: "DocSource" }, required: true },
272
+ handoff: { kind: :ref, ref: "Handoff", required: true },
273
+ held: { kind: :bool, required: true },
274
+ muted: { kind: :bool, required: true },
275
+ transfer: { kind: :ref, null: true, ref: "TransferState", required: true },
276
+ attention: { kind: :ref, null: true, ref: "AttentionState" },
277
+ usage: { kind: :list, items: { kind: :ref, ref: "ModelUsage" }, required: true },
278
+ cost: { kind: :ref, null: true, ref: "Cost", required: true },
279
+ routes: { kind: :list, items: { kind: :ref, ref: "Route" }, required: true },
280
+ gaps: { kind: :list, items: { kind: :ref, ref: "Gap" }, required: true },
281
+ errors: { kind: :list, items: { kind: :ref, ref: "LoggedError" }, required: true },
282
+ custom: { kind: :list, items: { kind: :ref, ref: "CustomNote" }, required: true }
283
+ }.freeze,
284
+ "SayVerb" => {
285
+ verb: { kind: :const, const: "say", required: true },
286
+ text: { kind: :str, required: true }
287
+ }.freeze,
288
+ "WhisperVerb" => {
289
+ verb: { kind: :const, const: "whisper", required: true },
290
+ text: { kind: :str, required: true }
291
+ }.freeze,
292
+ "TakeoverVerb" => {
293
+ verb: { kind: :const, const: "takeover", required: true }
294
+ }.freeze,
295
+ "ReleaseVerb" => {
296
+ verb: { kind: :const, const: "release", required: true }
297
+ }.freeze,
298
+ "TransferVerb" => {
299
+ verb: { kind: :const, const: "transfer", required: true },
300
+ to: { kind: :str, required: true },
301
+ mode: { kind: :ref, ref: "TransferMode" }
302
+ }.freeze,
303
+ "EndVerb" => {
304
+ verb: { kind: :const, const: "end", required: true },
305
+ reason: { kind: :str }
306
+ }.freeze,
307
+ "DevRefusal" => {
308
+ status: { kind: :int, required: true },
309
+ detail: { kind: :str, required: true }
310
+ }.freeze,
311
+ "JudgedBy" => {
312
+ provider: { kind: :str, null: true, required: true },
313
+ model: { kind: :str, null: true, required: true },
314
+ criteria: { kind: :str, required: true }
315
+ }.freeze,
316
+ "Judgment" => {
317
+ name: { kind: :str, required: true },
318
+ verdict: { kind: :ref, ref: "ScoreVerdict", required: true },
319
+ criteria: { kind: :str, required: true },
320
+ reason: { kind: :str, required: true },
321
+ evidence: { kind: :ref, ref: "JudgmentEvidence", required: true }
322
+ }.freeze,
323
+ "JudgmentEvidence" => {
324
+ seqs: { kind: :list, items: { kind: :int }, required: true },
325
+ said: { kind: :str }
326
+ }.freeze,
327
+ "StateCauseTool" => {
328
+ kind: { kind: :const, const: "tool", required: true },
329
+ tool: { kind: :str, required: true },
330
+ call_id: { kind: :str, required: true }
331
+ }.freeze,
332
+ "StateCauseEvent" => {
333
+ kind: { kind: :const, const: "event", required: true },
334
+ name: { kind: :str, required: true },
335
+ seq: { kind: :int, required: true }
336
+ }.freeze,
337
+ }.freeze
338
+ end
339
+ end
340
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The state an empty log starts from, and the parts a fold writes into it.
4
+
5
+ module Pinecall
6
+ module Wire
7
+ # The state a log reduces to.
8
+ module State
9
+ module_function
10
+
11
+ # The state of an empty log. Identical in all three languages, so one log reduces the same.
12
+ def initial
13
+ {
14
+ seq: 0,
15
+ agent: "",
16
+ call: nil,
17
+ status: "idle",
18
+ channel: nil,
19
+ direction: nil,
20
+ from: nil,
21
+ to: nil,
22
+ caller: nil,
23
+ room: nil,
24
+ started_at: nil,
25
+ ended_at: nil,
26
+ end_reason: nil,
27
+ outcome: nil,
28
+ user_state: nil,
29
+ agent_state: nil,
30
+ live: { user: nil, agent: nil },
31
+ turns: [],
32
+ metrics: { llm: [], stt: [], tts: [], vad: [], eou: [], eot: [], interruption: [], realtime: [], avatar: [] },
33
+ tools: [],
34
+ app_state: {},
35
+ events: [],
36
+ prompt: {},
37
+ tools_visible: [],
38
+ confirms: [],
39
+ memory: [],
40
+ sources: [],
41
+ handoff: { active: false, by: nil },
42
+ held: false,
43
+ muted: false,
44
+ transfer: nil,
45
+ attention: nil,
46
+ usage: [],
47
+ cost: nil,
48
+ routes: [],
49
+ gaps: [],
50
+ errors: [],
51
+ custom: []
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Checks a value against a named shape of the table, field by field, refusing a key nobody declared.
4
+
5
+ module Pinecall
6
+ module Wire
7
+ # Validates a payload against the generated shape tables and names the offending field.
8
+ # The Ruby counterpart of zod and pydantic; a table walk avoids a schema-library dependency.
9
+ module Validate
10
+ module_function
11
+
12
+ # Validate a payload against the named shape and return it. Shapes are closed: an undeclared
13
+ # key (e.g. from a newer protocol) is refused rather than silently dropped.
14
+ def call!(name, value, where: name)
15
+ return union!(name, value, where) if Shapes::UNIONS.key?(name)
16
+ return map!(Shapes::MAPS.fetch(name), value, where) if Shapes::MAPS.key?(name)
17
+
18
+ shape = Shapes::SHAPES[name]
19
+ raise WireError, "#{where}: the protocol declares no shape called #{name}" if shape.nil?
20
+ raise WireError, "#{where}: expected the fields of #{name}, got #{named(value)}" unless value.is_a?(Hash)
21
+
22
+ unknown = value.keys - shape.keys
23
+ raise WireError, "#{where}: #{name} has no field called #{unknown.join(", ")}" unless unknown.empty?
24
+
25
+ shape.each { |field, spec| field_of!(shape: name, spec:, value:, field:, where:) }
26
+ value
27
+ end
28
+
29
+ # One field. An optional one may be absent or null, as TypeScript's nullish reads it: the
30
+ # runtime writes null for a field it set to nothing.
31
+ def field_of!(shape:, spec:, value:, field:, where:)
32
+ return if !spec[:required] && value[field].nil?
33
+ raise WireError, "#{where}.#{field}: #{shape} requires it" unless value.key?(field)
34
+
35
+ field!(spec, value[field], "#{where}.#{field}")
36
+ end
37
+
38
+ # One value against a field descriptor; one branch per kind the generator emits.
39
+ def field!(spec, value, where)
40
+ if value.nil?
41
+ return if spec[:null]
42
+
43
+ raise WireError, "#{where}: nothing is not one of the things it may be"
44
+ end
45
+
46
+ case spec[:kind]
47
+ when :str then string!(spec, value, where)
48
+ when :int then expect(value, Integer, where, "a whole number")
49
+ when :float then expect(value, Numeric, where, "a number")
50
+ when :bool then expect_boolean(value, where)
51
+ when :json then expect(value, Hash, where, "an object")
52
+ when :any then value
53
+ when :list then list!(spec, value, where)
54
+ when :tuple then tuple!(spec, value, where)
55
+ when :map then map!(spec[:items], value, where)
56
+ when :enum then one_of!(spec[:values], value, where)
57
+ when :const then const!(spec[:const], value, where)
58
+ when :ref then ref!(spec[:ref], value, where)
59
+ else raise WireError, "#{where}: the generator emitted a kind nobody reads: #{spec[:kind]}"
60
+ end
61
+ end
62
+
63
+ # A union is told apart by the one field the schema's discriminator names.
64
+ def union!(name, value, where)
65
+ union = Shapes::UNIONS.fetch(name)
66
+ on = union[:on]
67
+ said = value.is_a?(Hash) ? value[on] : nil
68
+ member = union[:members].find { |one| Shapes::SHAPES.dig(one, on, :const) == said }
69
+ if member.nil?
70
+ allowed = union[:members].filter_map { |one| Shapes::SHAPES.dig(one, on, :const) }
71
+ raise WireError, "#{where}: #{on} #{said.inspect} is not one of #{allowed.join(", ")}"
72
+ end
73
+ call!(member, value, where:)
74
+ end
75
+
76
+ # A reference points at another object, at a union, or at one of the closed lists.
77
+ def ref!(name, value, where)
78
+ allowed = Enums::ALL[name]
79
+ return one_of!(allowed, value, where) unless allowed.nil?
80
+
81
+ call!(name, value, where:)
82
+ end
83
+
84
+ def list!(spec, value, where)
85
+ expect(value, Array, where, "a list")
86
+ value.each_with_index { |item, at| field!(spec[:items], item, "#{where}[#{at}]") }
87
+ end
88
+
89
+ # A tuple has a fixed length and one shape per position, e.g. a transcript line [who, what].
90
+ def tuple!(spec, value, where)
91
+ expect(value, Array, where, "a list")
92
+ places = spec[:members]
93
+ unless value.length == places.length
94
+ raise WireError, "#{where}: a list of #{places.length} things, not #{value.length}"
95
+ end
96
+
97
+ places.each_with_index { |place, at| field!(place, value[at], "#{where}[#{at}]") }
98
+ end
99
+
100
+ # Map keys are app-chosen names; every value has the same shape.
101
+ def map!(spec, value, where)
102
+ expect(value, Hash, where, "an object")
103
+ value.each { |key, item| field!(spec, item, "#{where}.#{key}") }
104
+ end
105
+
106
+ # JSON Schema's ^ and $ anchor the whole string but Ruby's anchor a line, so a value with a
107
+ # newline is refused before matching.
108
+ def string!(spec, value, where)
109
+ expect(value, String, where, "a string")
110
+ pattern = spec[:pattern]
111
+ return value if pattern.nil? || (!value.include?("\n") && Regexp.new(pattern).match?(value))
112
+
113
+ raise WireError, "#{where}: #{value.inspect} does not match #{pattern}"
114
+ end
115
+
116
+ def one_of!(values, value, where)
117
+ return value if values.include?(value)
118
+
119
+ raise WireError, "#{where}: #{value.inspect} is not one of #{values.join(", ")}"
120
+ end
121
+
122
+ def const!(const, value, where)
123
+ return value if value == const
124
+
125
+ raise WireError, "#{where}: this field is always #{const.inspect}, never #{value.inspect}"
126
+ end
127
+
128
+ def expect(value, type, where, called)
129
+ return value if value.is_a?(type)
130
+
131
+ raise WireError, "#{where}: expected #{called}, got #{named(value)}"
132
+ end
133
+
134
+ def expect_boolean(value, where)
135
+ return value if value == true || value == false
136
+
137
+ raise WireError, "#{where}: expected true or false, got #{named(value)}"
138
+ end
139
+
140
+ # Describe a value's type in the words the error messages use.
141
+ def named(value)
142
+ case value
143
+ when nil then "nothing"
144
+ when String then "a string"
145
+ when Integer, Float then "a number"
146
+ when true, false then "true or false"
147
+ when Array then "a list"
148
+ when Hash then "an object"
149
+ else value.class.name
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The runtime's wire as this gem speaks it: the shapes, a validator, the codec and the reducer.
4
+
5
+ require "json"
6
+ require "set"
7
+
8
+ require_relative "wire/errors"
9
+ require_relative "wire/enums"
10
+ require_relative "wire/shapes"
11
+ require_relative "wire/registry"
12
+ require_relative "wire/validate"
13
+ require_relative "wire/codec"
14
+ require_relative "wire/state"
15
+ require_relative "wire/reduce"
16
+
17
+ module Pinecall
18
+ # The wire between gateway and app: events the gateway writes, commands the app sends.
19
+ module Wire
20
+ class << self
21
+ # One log line from parsed JSON.
22
+ def decode_entry(raw) = Codec.decode_entry(raw)
23
+
24
+ # The entry's data as the shape its type names.
25
+ def event_of(entry) = Codec.event_of(entry)
26
+
27
+ # One command frame, checked against the shape its type names.
28
+ def command(...) = Codec.command(...)
29
+
30
+ # A frame as JSON text.
31
+ def encode(frame) = Codec.encode(frame)
32
+
33
+ # Fold a log into the state it means.
34
+ def reduce(entries) = Reduce.reduce(entries)
35
+
36
+ # One entry folded into a state, for a reader following a log as it happens.
37
+ def apply(state, entry) = Reduce.apply(state, entry)
38
+
39
+ # The state of an empty log.
40
+ def initial_state = State.initial
41
+ end
42
+ end
43
+ end
data/lib/pinecall.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pinecall's Ruby SDK: write an agent as a class and serve its calls over the gateway socket.
4
+ #
5
+ # `Pinecall::Agent` and `Pinecall.mount` are the framework; `Pinecall::Client` is the bare socket
6
+ # for applications that decide their own answers.
7
+
8
+ require "json"
9
+ require "pinecall/wire"
10
+
11
+ require_relative "pinecall/version"
12
+ require_relative "pinecall/errors"
13
+ require_relative "pinecall/reading"
14
+ require_relative "pinecall/view"
15
+ require_relative "pinecall/rules"
16
+ require_relative "pinecall/blocks"
17
+ require_relative "pinecall/panel"
18
+ require_relative "pinecall/agent"
19
+ require_relative "pinecall/call_world"
20
+ require_relative "pinecall/client"
21
+ require_relative "pinecall/bridge"
22
+ require_relative "pinecall/serve"
23
+
24
+ module Pinecall
25
+ class << self
26
+ # Mount an agent class on a client; each call gets its own instance. Nothing is sent until
27
+ # `connect`.
28
+ def mount(klass, client:, **options) = Bridge.mount(klass, client:, **options)
29
+
30
+ # Render the agent's prompt blocks in send order, offline.
31
+ def render(agent, **context) = Prompt.render(agent, **context)
32
+
33
+ # Render the prompt as one page, each block under its header.
34
+ def show_prompt(agent, **context) = Prompt.show(agent, **context)
35
+
36
+ # Serve an agent class from inside your own Ruby process: mounted on a client of its own (or
37
+ # the one given), connected, and held. `held.stop` drains then closes, for an `at_exit`.
38
+ def serve(klass, client: nil, url: nil, api_key: nil, env: nil, slug: nil, last: nil)
39
+ client ||= Client.new(url:, api_key:, env:)
40
+ mounted = mount(klass, client:, slug: slug || klass.slug, last:)
41
+ client.connect
42
+ Serve::Held.new(client, [mounted])
43
+ end
44
+ end
45
+ end