pinecall-runtime 0.1.6__py3-none-any.whl

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 (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,204 @@
1
+ """The vault: the Fernet keys every secret is sealed under, and the vendors' credentials."""
2
+
3
+ import json
4
+ import logging
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+
8
+ from cryptography.fernet import Fernet, InvalidToken, MultiFernet
9
+ from psycopg import sql
10
+ from psycopg.types.json import Jsonb
11
+
12
+ from pinecall.domain.names import Credentials, Json
13
+ from pinecall.postgres.pool import Pool
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # The box's own credentials sit beside its other settings, one row per vendor.
18
+ BOX_CREDENTIALS = "credentials/"
19
+
20
+ OWN = "SELECT vendor, ciphertext FROM provider_keys WHERE org = %(org)s ORDER BY vendor"
21
+ PUT_OWN = """
22
+ INSERT INTO provider_keys (org, vendor, ciphertext) VALUES (%(org)s, %(vendor)s, %(ciphertext)s)
23
+ ON CONFLICT (org, vendor) DO UPDATE SET ciphertext = excluded.ciphertext, set_at = now()
24
+ """
25
+ DROP_OWN = "DELETE FROM provider_keys WHERE org = %(org)s AND vendor = %(vendor)s RETURNING vendor"
26
+ BOX = """
27
+ SELECT substr(name, length(%(prefix)s) + 1) AS vendor, ciphertext FROM box_settings
28
+ WHERE starts_with(name, %(prefix)s) AND ciphertext IS NOT NULL ORDER BY name
29
+ """
30
+ PUT_BOX = """
31
+ INSERT INTO box_settings (name, value, ciphertext) VALUES (%(name)s, %(value)s, %(ciphertext)s)
32
+ ON CONFLICT (name) DO UPDATE SET ciphertext = excluded.ciphertext, set_at = now()
33
+ """
34
+ DROP_BOX = "DELETE FROM box_settings WHERE name = %(name)s RETURNING name"
35
+
36
+
37
+ # A row's ctid names it for one statement, and the value it still holds guards the write: a row
38
+ # the gateway wrote meanwhile was written under the first key already, and is left as it is.
39
+ TOKENS = "SELECT ctid::text AS row, {column} AS token FROM {table} WHERE {column} IS NOT NULL"
40
+ RESEAL = "UPDATE {table} SET {column} = %(new)s WHERE ctid = %(row)s::tid AND {column} = %(old)s"
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class SealedColumn:
45
+ """A column a secret is kept sealed in, by its table."""
46
+
47
+ table: str
48
+ column: str
49
+
50
+ @property
51
+ def named(self) -> str:
52
+ """The column as a person reads it: table.column."""
53
+ return f"{self.table}.{self.column}"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Resealing:
58
+ """One column after a pass: re-sealed now, already under the first key, opened by no key."""
59
+
60
+ column: SealedColumn
61
+ resealed: int = 0
62
+ current: int = 0
63
+ unopened: int = 0
64
+
65
+
66
+ # Every column of the schema a secret is sealed in. A test walks the schema and every Fernet token
67
+ # in it, so a column added without its line here fails the suite.
68
+ SEALED_COLUMNS: tuple[SealedColumn, ...] = (
69
+ SealedColumn("box_settings", "ciphertext"),
70
+ SealedColumn("call_private", "sealed"),
71
+ SealedColumn("carriers", "ciphertext"),
72
+ SealedColumn("hosted_apps", "sealed_key"),
73
+ SealedColumn("one_use_words", "sealed"),
74
+ SealedColumn("org_mail", "ciphertext"),
75
+ SealedColumn("org_secrets", "sealed"),
76
+ SealedColumn("org_sso", "ciphertext"),
77
+ SealedColumn("provider_keys", "ciphertext"),
78
+ SealedColumn("recording_keys", "sealed"),
79
+ )
80
+
81
+
82
+ def sealed(vault: MultiFernet, secret: Json) -> str:
83
+ """The secret as one Fernet token over its JSON."""
84
+ return vault.encrypt(json.dumps(secret).encode()).decode()
85
+
86
+
87
+ # A token sealed under a key the list no longer holds reads as nothing, said in the log: one
88
+ # stale row must not stop every call of the org.
89
+ def opened(vault: MultiFernet, token: str) -> Json:
90
+ """The secret a token seals; None when no key listed opens it."""
91
+ try:
92
+ return json.loads(vault.decrypt(token.encode()))
93
+ except InvalidToken:
94
+ logger.warning(
95
+ "a secret sealed under a key PINECALL_VAULT_KEY no longer lists reads as unset"
96
+ )
97
+ return None
98
+
99
+
100
+ async def credentials_of(pool: Pool, vault: MultiFernet, org: str) -> dict[str, Credentials]:
101
+ """The vendors' credentials the org brought, opened."""
102
+ async with pool.connection() as connection:
103
+ rows = await (await connection.execute(OWN, {"org": org})).fetchall()
104
+ return _opened_rows(vault, [(row["vendor"], row["ciphertext"]) for row in rows])
105
+
106
+
107
+ async def vendors_of(pool: Pool, org: str) -> list[str]:
108
+ """The vendors the org brought its own credentials for, never the credentials."""
109
+ async with pool.connection() as connection:
110
+ rows = await (await connection.execute(OWN, {"org": org})).fetchall()
111
+ return [row["vendor"] for row in rows]
112
+
113
+
114
+ async def put_credentials(
115
+ pool: Pool, vault: MultiFernet, org: str, vendor: str, credentials: Credentials
116
+ ) -> None:
117
+ """Keep the org's credentials for the vendor, replacing any it had."""
118
+ ciphertext = sealed(vault, credentials)
119
+ async with pool.connection() as connection:
120
+ await connection.execute(PUT_OWN, {"org": org, "vendor": vendor, "ciphertext": ciphertext})
121
+
122
+
123
+ async def drop_credentials(pool: Pool, org: str, vendor: str) -> bool:
124
+ """Forget the org's credentials for the vendor; whether it had any."""
125
+ async with pool.connection() as connection:
126
+ dropped = await connection.execute(DROP_OWN, {"org": org, "vendor": vendor})
127
+ return await dropped.fetchone() is not None
128
+
129
+
130
+ async def box_credentials(pool: Pool, vault: MultiFernet) -> dict[str, Credentials]:
131
+ """The credentials the box holds for the vendors it offers, opened."""
132
+ async with pool.connection() as connection:
133
+ rows = await (await connection.execute(BOX, {"prefix": BOX_CREDENTIALS})).fetchall()
134
+ return _opened_rows(vault, [(row["vendor"], row["ciphertext"]) for row in rows])
135
+
136
+
137
+ async def put_box_credentials(
138
+ pool: Pool, vault: MultiFernet, vendor: str, credentials: Credentials
139
+ ) -> None:
140
+ """Keep the box's credentials for the vendor: offering it is holding its key."""
141
+ row = {
142
+ "name": f"{BOX_CREDENTIALS}{vendor}",
143
+ "value": Jsonb({}),
144
+ "ciphertext": sealed(vault, credentials),
145
+ }
146
+ async with pool.connection() as connection:
147
+ await connection.execute(PUT_BOX, row)
148
+
149
+
150
+ async def drop_box_credentials(pool: Pool, vendor: str) -> bool:
151
+ """Stop offering the vendor on the box's key; whether the box held one."""
152
+ async with pool.connection() as connection:
153
+ dropped = await connection.execute(DROP_BOX, {"name": f"{BOX_CREDENTIALS}{vendor}"})
154
+ return await dropped.fetchone() is not None
155
+
156
+
157
+ # Row by row, each its own statement: a pass cut short keeps what it did, and the next pass
158
+ # finds those rows under the first key and leaves them. The first key must be first everywhere
159
+ # (the gateway restarted on the new list) before the old one is taken out.
160
+ async def resealed(pool: Pool, keyring: Sequence[Fernet]) -> list[Resealing]:
161
+ """Seal every sealed value of the schema under the keyring's first key, and count each."""
162
+ return [await _resealed_column(pool, keyring, column) for column in SEALED_COLUMNS]
163
+
164
+
165
+ async def _resealed_column(
166
+ pool: Pool, keyring: Sequence[Fernet], column: SealedColumn
167
+ ) -> Resealing:
168
+ names = {"table": sql.Identifier(column.table), "column": sql.Identifier(column.column)}
169
+ ring, first = MultiFernet(keyring), keyring[0]
170
+ async with pool.connection() as connection:
171
+ rows = await (await connection.execute(sql.SQL(TOKENS).format(**names))).fetchall()
172
+ resealed = current = unopened = 0
173
+ for row in rows:
174
+ token = str(row["token"]).encode()
175
+ if _opens(first, token):
176
+ current += 1
177
+ continue
178
+ try:
179
+ new = ring.rotate(token).decode()
180
+ except InvalidToken:
181
+ unopened += 1
182
+ continue
183
+ values = {"new": new, "row": row["row"], "old": row["token"]}
184
+ async with pool.connection() as connection:
185
+ written = await connection.execute(sql.SQL(RESEAL).format(**names), values)
186
+ resealed += written.rowcount
187
+ return Resealing(column, resealed=resealed, current=current, unopened=unopened)
188
+
189
+
190
+ def _opens(key: Fernet, token: bytes) -> bool:
191
+ try:
192
+ key.decrypt(token)
193
+ except InvalidToken:
194
+ return False
195
+ return True
196
+
197
+
198
+ def _opened_rows(vault: MultiFernet, rows: list[tuple[str, str]]) -> dict[str, Credentials]:
199
+ kept: dict[str, Credentials] = {}
200
+ for vendor, ciphertext in rows:
201
+ secret = opened(vault, ciphertext)
202
+ if isinstance(secret, str | dict):
203
+ kept[vendor] = secret
204
+ return kept
@@ -0,0 +1,144 @@
1
+ """Words spent once, kept in Postgres: any gateway mints one, any gateway spends it."""
2
+
3
+ import hashlib
4
+ import secrets
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+
9
+ from cryptography.fernet import MultiFernet
10
+ from pydantic import TypeAdapter
11
+
12
+ from pinecall.postgres.pool import Pool
13
+
14
+ WORD_BYTES = 24
15
+
16
+ # The word is kept as its hash and the value sealed: a copy of the table opens nothing.
17
+ KEPT = """
18
+ insert into one_use_words (word_hash, kind, sealed, expires_at)
19
+ values (%(hash)s, %(kind)s, %(sealed)s, %(expires_at)s)
20
+ on conflict (word_hash) do update
21
+ set kind = excluded.kind, sealed = excluded.sealed, expires_at = excluded.expires_at, attempts = 0
22
+ """
23
+
24
+ READ = "select sealed, expires_at, attempts from one_use_words where word_hash = %(hash)s"
25
+
26
+ # Spent once: of two gateways spending one word, one gets the value.
27
+ SPENT = """
28
+ delete from one_use_words where word_hash = %(hash)s and expires_at > %(now)s
29
+ returning sealed, expires_at
30
+ """
31
+
32
+ # The value changed in place, its end kept: a terminal given its key.
33
+ FILLED = """
34
+ update one_use_words set sealed = %(sealed)s
35
+ where word_hash = %(hash)s and expires_at > %(now)s
36
+ returning word_hash
37
+ """
38
+
39
+ TRIED = """
40
+ update one_use_words set attempts = attempts + 1 where word_hash = %(hash)s returning attempts
41
+ """
42
+
43
+ FORGOTTEN = "delete from one_use_words where word_hash = %(hash)s"
44
+
45
+ SWEPT = "delete from one_use_words where expires_at <= %(now)s"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Words:
50
+ """Where words are kept, the vault their values are sealed under, and the clock they die by."""
51
+
52
+ pool: Pool
53
+ vault: MultiFernet
54
+ clock: Callable[[], float] = time.time
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class OneUseValue[T]:
59
+ """A value kept under a one-use word, until when, and how often a guess at it failed."""
60
+
61
+ value: T
62
+ expires_at: float
63
+ attempts: int = 0
64
+
65
+
66
+ class OneUse[T]:
67
+ """Words of one kind spent once, each dying on its own."""
68
+
69
+ def __init__(self, kind: tuple[str, float], adapter: TypeAdapter[T], words: Words) -> None:
70
+ """The kind is the words' prefix and how long they live; the adapter seals their values."""
71
+ self.prefix, self.ttl_s = kind
72
+ self.adapter = adapter
73
+ self.words = words
74
+
75
+ def word(self) -> str:
76
+ """A new word, not yet holding anything."""
77
+ return f"{self.prefix}{secrets.token_urlsafe(WORD_BYTES)}"
78
+
79
+ async def keep(self, word: str, value: T) -> float:
80
+ """Hold the value under the word until it dies, and say when."""
81
+ now = self.words.clock()
82
+ expires_at = now + self.ttl_s
83
+ row = {
84
+ "hash": _hashed(word),
85
+ "kind": self.prefix,
86
+ "sealed": self._sealed(value),
87
+ "expires_at": expires_at,
88
+ }
89
+ async with self.words.pool.connection() as connection, connection.transaction():
90
+ await connection.execute(SWEPT, {"now": now})
91
+ await connection.execute(KEPT, row)
92
+ return expires_at
93
+
94
+ async def mint(self, value: T) -> tuple[str, float]:
95
+ """A new word holding the value, and when it dies."""
96
+ word = self.word()
97
+ return word, await self.keep(word, value)
98
+
99
+ async def read(self, word: str) -> OneUseValue[T] | None:
100
+ """What the word holds, dead or not, without spending it."""
101
+ async with self.words.pool.connection() as connection:
102
+ row = await (await connection.execute(READ, {"hash": _hashed(word)})).fetchone()
103
+ if row is None:
104
+ return None
105
+ return OneUseValue(self._opened(row["sealed"]), row["expires_at"], row["attempts"])
106
+
107
+ async def alive(self, word: str) -> OneUseValue[T] | None:
108
+ """What the word holds while it lives, without spending it."""
109
+ found = await self.read(word)
110
+ return None if found is None or found.expires_at <= self.words.clock() else found
111
+
112
+ async def spend(self, word: str) -> T | None:
113
+ """What the word holds, spent: it opens nothing after."""
114
+ wanted = {"hash": _hashed(word), "now": self.words.clock()}
115
+ async with self.words.pool.connection() as connection:
116
+ row = await (await connection.execute(SPENT, wanted)).fetchone()
117
+ return None if row is None else self._opened(row["sealed"])
118
+
119
+ async def fill(self, word: str, value: T) -> bool:
120
+ """Change what a living word holds, its end kept; False for a dead one."""
121
+ wanted = {"hash": _hashed(word), "now": self.words.clock(), "sealed": self._sealed(value)}
122
+ async with self.words.pool.connection() as connection:
123
+ return await (await connection.execute(FILLED, wanted)).fetchone() is not None
124
+
125
+ async def tried(self, word: str) -> int:
126
+ """Count one more wrong guess at the word, and say how many there were."""
127
+ async with self.words.pool.connection() as connection:
128
+ row = await (await connection.execute(TRIED, {"hash": _hashed(word)})).fetchone()
129
+ return 0 if row is None else int(row["attempts"])
130
+
131
+ async def forget(self, word: str) -> None:
132
+ """The word opens nothing from now on."""
133
+ async with self.words.pool.connection() as connection:
134
+ await connection.execute(FORGOTTEN, {"hash": _hashed(word)})
135
+
136
+ def _sealed(self, value: T) -> str:
137
+ return self.words.vault.encrypt(self.adapter.dump_json(value)).decode()
138
+
139
+ def _opened(self, sealed: str) -> T:
140
+ return self.adapter.validate_json(self.words.vault.decrypt(sealed.encode()))
141
+
142
+
143
+ def _hashed(word: str) -> bytes:
144
+ return hashlib.sha256(word.encode()).digest()
File without changes
@@ -0,0 +1,350 @@
1
+ """Every command an app sends, the supervise verbs, and the registry of them."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import Field
6
+
7
+ from pinecall.domain.errors import DeclarationRefused
8
+ from pinecall.domain.names import JsonObject
9
+ from pinecall.wire.frames import Command, WireModel
10
+ from pinecall.wire.parts import (
11
+ AgentConfig,
12
+ Contact,
13
+ Route,
14
+ Supervisor,
15
+ ToolResult,
16
+ ToolSpec,
17
+ TransferMode,
18
+ )
19
+
20
+ # Which events a command lands in the log as, so a caller knows what to wait for.
21
+ PRODUCES: dict[str, tuple[str, ...]] = {
22
+ "agent.configure": ("agent.configured",),
23
+ "agent.drain": ("agent.draining",),
24
+ "agent.register": ("agent.registered",),
25
+ "agent.reply": ("turn.agent",),
26
+ "agent.say": ("turn.agent",),
27
+ "call.attention": ("attention.requested", "call.line", "attention.answered"),
28
+ "call.callback": ("callback.requested",),
29
+ "call.claim": ("call.claimed",),
30
+ "call.dial": ("call.dialing",),
31
+ "call.dtmf": (),
32
+ "call.opt_out": (),
33
+ "call.event": ("event.received",),
34
+ "call.hangup": ("call.ended",),
35
+ "call.hold": ("call.line",),
36
+ "call.log": ("custom",),
37
+ "call.mute": ("call.line",),
38
+ "call.transfer": ("call.transferred",),
39
+ "call.unhold": ("call.line",),
40
+ "call.unmute": ("call.line",),
41
+ "dev.answer": (),
42
+ "participant.mute": ("track.unpublished",),
43
+ "participant.remove": ("participant.left",),
44
+ "ping": ("pong",),
45
+ "prompt.set": ("prompt.changed",),
46
+ "room.invite": ("participant.joined",),
47
+ "room.send": ("room.sent",),
48
+ "session.configure": ("state.changed", "agent.configured"),
49
+ "state.set": ("state.changed",),
50
+ "supervisor.verb": (
51
+ "supervisor.said",
52
+ "supervisor.whispered",
53
+ "supervisor.took_over",
54
+ "supervisor.released",
55
+ "supervisor.transferred",
56
+ "supervisor.ended",
57
+ ),
58
+ "tool.result": ("tool.result",),
59
+ "tools.set": ("tools.changed",),
60
+ }
61
+
62
+
63
+ class SayVerb(WireModel):
64
+ """Make the agent say this, verbatim, to the caller."""
65
+
66
+ verb: Literal["say"] = "say"
67
+ text: str
68
+
69
+
70
+ class WhisperVerb(WireModel):
71
+ """Tell the agent something the caller never hears."""
72
+
73
+ verb: Literal["whisper"] = "whisper"
74
+ text: str
75
+
76
+
77
+ class TakeoverVerb(WireModel):
78
+ """The supervisor takes the line."""
79
+
80
+ verb: Literal["takeover"] = "takeover"
81
+
82
+
83
+ class ReleaseVerb(WireModel):
84
+ """The supervisor hands the line back to the agent."""
85
+
86
+ verb: Literal["release"] = "release"
87
+
88
+
89
+ class TransferVerb(WireModel):
90
+ """Send the caller to another number."""
91
+
92
+ verb: Literal["transfer"] = "transfer"
93
+ to: str
94
+ mode: TransferMode | None = None
95
+
96
+
97
+ class EndVerb(WireModel):
98
+ """Hang up on the caller's behalf, at once."""
99
+
100
+ verb: Literal["end"] = "end"
101
+ reason: str | None = None
102
+
103
+
104
+ type Verb = Annotated[
105
+ SayVerb | WhisperVerb | TakeoverVerb | ReleaseVerb | TransferVerb | EndVerb,
106
+ Field(discriminator="verb"),
107
+ ]
108
+
109
+
110
+ class AgentConfigure(WireModel):
111
+ """Declare or change what the agent is; only the fields sent change."""
112
+
113
+ config: AgentConfig
114
+
115
+
116
+ class AgentDrain(WireModel):
117
+ """This socket is leaving, and its calls go on without it."""
118
+
119
+
120
+ class AgentRegister(WireModel):
121
+ """The app's first message: this socket speaks for this agent and answers these doors."""
122
+
123
+ routes: list[Route]
124
+ sdk: str | None = None
125
+ host: str | None = None
126
+ takes_unclaimed: bool = True
127
+ answers_dev: bool = False
128
+
129
+
130
+ class AgentReply(WireModel):
131
+ """Make the model speak now, guided by an instruction it reads and the caller never hears."""
132
+
133
+ instructions: str
134
+ allow_interruptions: bool | None = None
135
+
136
+
137
+ class AgentSay(WireModel):
138
+ """Make the agent say this text now, verbatim, outside the model's turn."""
139
+
140
+ text: str
141
+ allow_interruptions: bool | None = None
142
+
143
+
144
+ class CallAttention(WireModel):
145
+ """Ask for a person without sending the caller anywhere."""
146
+
147
+ reason: str
148
+ # Fifteen minutes at most: past it nobody is coming, and the call holds a worker's seat.
149
+ wait_s: float = Field(gt=0, le=900)
150
+
151
+
152
+ class CallCallback(WireModel):
153
+ """Write down that the caller wants to be called back."""
154
+
155
+ number: str
156
+ when: str | None = None
157
+ note: str | None = None
158
+
159
+
160
+ class CallClaim(WireModel):
161
+ """The code the caller said, to bind this call to the page that shows it."""
162
+
163
+ code: str = Field(pattern="^[0-9]{4}$")
164
+
165
+
166
+ class CallDial(WireModel):
167
+ """Place an outbound call as this agent."""
168
+
169
+ to: str
170
+ from_: str | None = Field(None, alias="from")
171
+ caller: Contact | None = None
172
+ metadata: JsonObject | None = None
173
+
174
+
175
+ class CallOptOut(WireModel):
176
+ """The caller asked never to be called again: their number joins the org's do-not-call list."""
177
+
178
+ note: str | None = Field(default=None, max_length=500)
179
+
180
+
181
+ class CallDtmf(WireModel):
182
+ """Send touch tones down the line, for an IVR on the far end."""
183
+
184
+ digits: str
185
+
186
+
187
+ class CallEvent(WireModel):
188
+ """Hand the agent a fact from the tenant's backend; lands as event.received."""
189
+
190
+ name: str
191
+ data: JsonObject
192
+
193
+
194
+ class CallHangup(WireModel):
195
+ """End the call from the app's side."""
196
+
197
+ reason: str | None = None
198
+
199
+
200
+ class CallHold(WireModel):
201
+ """Put the caller on hold: they hear hold audio, the agent hears nothing."""
202
+
203
+
204
+ class CallLog(WireModel):
205
+ """Write a line of the app's own into the call's log."""
206
+
207
+ name: str
208
+ data: JsonObject
209
+
210
+
211
+ class CallMute(WireModel):
212
+ """Mute the agent: it keeps listening and thinking, produces no audio."""
213
+
214
+
215
+ class CallTransfer(WireModel):
216
+ """Send the caller to another number, or bring that number into the call."""
217
+
218
+ to: str
219
+ mode: TransferMode | None = None
220
+
221
+
222
+ class CallUnhold(WireModel):
223
+ """Take the caller off hold."""
224
+
225
+
226
+ class CallUnmute(WireModel):
227
+ """Unmute the agent."""
228
+
229
+
230
+ class DevRefusal(WireModel):
231
+ """Why the verb did not run, in the words the console shows."""
232
+
233
+ status: int
234
+ detail: str
235
+
236
+
237
+ class DevAnswer(WireModel):
238
+ """What came of one dev.request, named by its id."""
239
+
240
+ id: str
241
+ result: JsonObject | None = None
242
+ refused: DevRefusal | None = None
243
+
244
+
245
+ class ParticipantMute(WireModel):
246
+ """Silence a participant for the rest of the call."""
247
+
248
+ identity: str
249
+
250
+
251
+ class ParticipantRemove(WireModel):
252
+ """Put a participant out of the room; removing the caller ends the call."""
253
+
254
+ identity: str
255
+
256
+
257
+ class Ping(WireModel):
258
+ """Is the socket alive? The gateway answers pong."""
259
+
260
+
261
+ class PromptSet(WireModel):
262
+ """Rewrite one block of the prompt, whole, by name."""
263
+
264
+ name: str
265
+ text: str
266
+
267
+
268
+ class RoomInvite(WireModel):
269
+ """Bring somebody else into the call's room."""
270
+
271
+ to: str
272
+ kind: Literal["sip", "participant"]
273
+
274
+
275
+ class RoomSend(WireModel):
276
+ """Push a payload to a browser in the room over the DataChannel."""
277
+
278
+ topic: str
279
+ data: JsonObject
280
+ to: str | None = None
281
+
282
+
283
+ class SessionConfigure(WireModel):
284
+ """Set up this one call before the first turn."""
285
+
286
+ state: JsonObject | None = None
287
+ config: AgentConfig | None = None
288
+
289
+
290
+ class StateSet(WireModel):
291
+ """The app's state changed and this is all of it."""
292
+
293
+ state: JsonObject
294
+ changed: list[str] | None = None
295
+
296
+
297
+ class SupervisorVerb(WireModel):
298
+ """One supervise verb, from the human the door named."""
299
+
300
+ by: Supervisor
301
+ verb: Verb
302
+
303
+
304
+ class ToolsSet(WireModel):
305
+ """The tools the model may see now."""
306
+
307
+ tools: list[ToolSpec]
308
+
309
+
310
+ COMMANDS: dict[str, type[WireModel]] = {
311
+ "agent.configure": AgentConfigure,
312
+ "agent.drain": AgentDrain,
313
+ "agent.register": AgentRegister,
314
+ "agent.reply": AgentReply,
315
+ "agent.say": AgentSay,
316
+ "call.attention": CallAttention,
317
+ "call.callback": CallCallback,
318
+ "call.claim": CallClaim,
319
+ "call.dial": CallDial,
320
+ "call.dtmf": CallDtmf,
321
+ "call.opt_out": CallOptOut,
322
+ "call.event": CallEvent,
323
+ "call.hangup": CallHangup,
324
+ "call.hold": CallHold,
325
+ "call.log": CallLog,
326
+ "call.mute": CallMute,
327
+ "call.transfer": CallTransfer,
328
+ "call.unhold": CallUnhold,
329
+ "call.unmute": CallUnmute,
330
+ "dev.answer": DevAnswer,
331
+ "participant.mute": ParticipantMute,
332
+ "participant.remove": ParticipantRemove,
333
+ "ping": Ping,
334
+ "prompt.set": PromptSet,
335
+ "room.invite": RoomInvite,
336
+ "room.send": RoomSend,
337
+ "session.configure": SessionConfigure,
338
+ "state.set": StateSet,
339
+ "supervisor.verb": SupervisorVerb,
340
+ "tool.result": ToolResult,
341
+ "tools.set": ToolsSet,
342
+ }
343
+
344
+
345
+ def command_of(command: Command) -> WireModel:
346
+ """Return the command's data as the model its type names; an unknown type is refused."""
347
+ model = COMMANDS.get(command.type)
348
+ if model is None:
349
+ raise DeclarationRefused(f"unknown command type: {command.type}")
350
+ return model.read(command.data, command.type)