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,118 @@
1
+ """A session opened for a spoken call: the pipeline, the turn options, the ears' keyterms."""
2
+
3
+ from collections.abc import Sequence
4
+ from functools import cache
5
+
6
+ from livekit.agents import (
7
+ NOT_GIVEN,
8
+ NotGivenOr,
9
+ inference,
10
+ )
11
+ from livekit.agents.voice import AgentSession, STTContextOptions
12
+ from livekit.agents.voice import text_transforms as transforms
13
+ from livekit.agents.voice.agent_session import DEFAULT_TTS_TEXT_TRANSFORMS
14
+ from livekit.agents.voice.turn import InterruptionOptions, TurnDetectionMode, TurnHandlingOptions
15
+ from smart_turn_livekit import SmartTurnDetector # pyright: ignore[reportMissingTypeStubs]
16
+
17
+ from pinecall.domain.agent import AgentConfig
18
+ from pinecall.providers.build import (
19
+ Ears,
20
+ Running,
21
+ TurnModel,
22
+ ears_of,
23
+ speaking_of,
24
+ thinking_of,
25
+ )
26
+ from pinecall.providers.credentials import Pipeline
27
+ from pinecall.session import tools
28
+ from pinecall.session._hearing import keyterms, policy_for
29
+ from pinecall.session._livekit import switches_written
30
+ from pinecall.session.call import Call
31
+ from pinecall.session.session import ONE_ANSWER_PER_TOOL, Session
32
+ from pinecall.session.tools import VOICE_LOOKUP_MS
33
+
34
+ # The local end-of-turn model: left unset, livekit may pick the hosted one and send the
35
+ # caller's words to a cloud.
36
+ LOCAL_TURN_VERSION: inference.TurnDetectorVersions = "v1-mini"
37
+
38
+ # The checkpoint in smart-turn-livekit's registry: upstream's, benchmarked on 23 languages.
39
+ SMART_TURN: TurnModel = "smart-turn-v3"
40
+
41
+
42
+ def voice_session(call: Call, stages: Pipeline) -> Session:
43
+ """A voice call: the three stages built for it, the turn taken as a phone line needs."""
44
+ thinking, ears, voice = (
45
+ thinking_of(stages.llm),
46
+ ears_of(stages.stt, call.config.turn),
47
+ speaking_of(stages.tts),
48
+ )
49
+ switches_written(call.writing, (thinking, ears, voice))
50
+ live: AgentSession[None] = AgentSession(
51
+ llm=thinking,
52
+ stt=ears,
53
+ tts=voice,
54
+ turn_handling=_spoken_turns(call.config, stages.stt),
55
+ # Word timings reach transcription_node only from an aligned voice.
56
+ use_tts_aligned_transcript=True,
57
+ tts_text_transforms=_text_transforms_of(call.config),
58
+ stt_context_options=context_of(call.config, ears),
59
+ max_tool_steps=ONE_ANSWER_PER_TOOL,
60
+ )
61
+ return Session(
62
+ live, (thinking, ears, voice), tools.Lookups(call, call.platform.lookup, VOICE_LOOKUP_MS)
63
+ )
64
+
65
+
66
+ def context_of(config: AgentConfig, ears: Ears) -> NotGivenOr[STTContextOptions]:
67
+ """The keyterms the ears are told at start, when they take any."""
68
+ if not config.hears or not ears.capabilities.keyterms:
69
+ return NOT_GIVEN
70
+ return {"keyterms": keyterms(config, {})}
71
+
72
+
73
+ # One Smart Turn per process: its weights load once and its first inference runs when it is made,
74
+ # so only the first call that asks for it pays. Each session streams to it on its own.
75
+ @cache
76
+ def smart_turn() -> SmartTurnDetector:
77
+ """Smart Turn v3, the row's alternative to livekit's own detector."""
78
+ return SmartTurnDetector(SMART_TURN)
79
+
80
+
81
+ def _end_of_turn(model: TurnModel) -> TurnDetectionMode:
82
+ """The local model that reads the end of the caller's turn off the audio, no transcript."""
83
+ if model == SMART_TURN:
84
+ return smart_turn()
85
+ return inference.TurnDetector(version=LOCAL_TURN_VERSION)
86
+
87
+
88
+ # The parameter replaces livekit's defaults, so they come first and the tenant's words after.
89
+ def _text_transforms_of(config: AgentConfig) -> NotGivenOr[Sequence[transforms.TextTransforms]]:
90
+ """Livekit's own transforms, then the tenant's pronunciations."""
91
+ if not config.says:
92
+ return NOT_GIVEN
93
+ return [*DEFAULT_TTS_TEXT_TRANSFORMS, transforms.replace(dict(config.says))]
94
+
95
+
96
+ # Interruptions are judged by the local VAD: livekit's adaptive detector streams the caller's
97
+ # audio to its cloud. A false interruption is not resumed: livekit replays the whole sentence.
98
+ # No endpointing here: the agent's own already reaches the ears, and both would wait twice.
99
+ def _spoken_turns(config: AgentConfig, ears: Running) -> TurnHandlingOptions:
100
+ r"""How the caller takes the floor, from the agent\'s language and its own turn knobs."""
101
+ policy = policy_for(config.language, config.turn)
102
+ interruption: InterruptionOptions = {
103
+ "min_words": policy.min_words,
104
+ "mode": "vad",
105
+ "false_interruption_timeout": policy.false_interruption_s,
106
+ "resume_false_interruption": False,
107
+ }
108
+ if policy.min_speech_s is not None:
109
+ interruption["min_duration"] = policy.min_speech_s
110
+ return {
111
+ # Ears that end the turn themselves decide it; the local detector stacked on them waits
112
+ # its whole delay after a pause in the middle of a sentence.
113
+ "turn_detection": "stt" if ears.ends_the_turn else _end_of_turn(ears.turn_model),
114
+ # A reply started inside a tool's window, on a context without its result, answers its
115
+ # own question.
116
+ "preemptive_generation": {"enabled": False},
117
+ "interruption": interruption,
118
+ }
@@ -0,0 +1,201 @@
1
+ """The DataChannel to a browser widget: the public log of its call, and the events it sends."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine
8
+ from dataclasses import dataclass
9
+
10
+ from livekit import rtc
11
+ from pydantic import TypeAdapter, ValidationError
12
+
13
+ from pinecall.domain.errors import PinecallError
14
+ from pinecall.domain.names import JsonObject
15
+ from pinecall.domain.person import READS_ITS_OWN_CALL
16
+ from pinecall.domain.scope import SCOPE_ATTRIBUTE
17
+ from pinecall.log.readers import project_entry, project_state
18
+ from pinecall.session.call import Call
19
+ from pinecall.wire.events import EventReceived
20
+ from pinecall.wire.frames import Entry
21
+ from pinecall.wire.state import State
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # The topics of docs/protocol/projections.md; pinecall.ui is room.send's.
26
+ LOG = "pinecall.log"
27
+ SNAPSHOT = "pinecall.snapshot"
28
+ REPLAY = "pinecall.replay"
29
+ EVENT = "pinecall.event"
30
+ _A_JSON_OBJECT: TypeAdapter[JsonObject] = TypeAdapter(JsonObject)
31
+
32
+ # What a widget's greeting waits for the log to hold its arrival.
33
+ FLUSH_S = 5.0
34
+
35
+
36
+ # Only the gateway numbers a log's entries, so what reaches a browser is read back from it,
37
+ # never taken from what the session wrote.
38
+ @dataclass(frozen=True)
39
+ class Reading:
40
+ """The call's log as the gateway holds it: the state now, a stretch of it, and its tail."""
41
+
42
+ state: Callable[[], Awaitable[tuple[State, int]]]
43
+ since: Callable[[int], AsyncIterator[Entry]]
44
+ tail: Callable[[int], AsyncIterator[Entry]]
45
+
46
+
47
+ class Widget:
48
+ """One call's channel to its widgets: one tail of the log, projected for each of them."""
49
+
50
+ def __init__(self, call: Call, room: rtc.Room, reading: Reading) -> None:
51
+ """A channel nobody listens to yet."""
52
+ self.call = call
53
+ self.room = room
54
+ self.reading = reading
55
+ # What each widget in the room has been sent, by its seat.
56
+ self.audience: dict[str, int] = {}
57
+ self.cursor = 0
58
+ self.tailing: asyncio.Task[None] | None = None
59
+ self.answering: set[asyncio.Task[None]] = set()
60
+
61
+ def watch(self) -> None:
62
+ """Greet each widget that is or comes into the room, and hear what it sends."""
63
+ for name, listener in self._listeners().items():
64
+ self.room.on(name, listener) # pyright: ignore[reportUnknownMemberType]
65
+
66
+ def stop(self) -> None:
67
+ """Let go of the room and of the tail."""
68
+ for name, listener in self._listeners().items():
69
+ self.room.off(name, listener) # pyright: ignore[reportUnknownMemberType]
70
+ for task in (self.tailing, *self.answering):
71
+ if task is not None:
72
+ task.cancel()
73
+ self.tailing = None
74
+ self.answering.clear()
75
+
76
+ def _listeners(self) -> dict[rtc.room.EventTypes, Callable[..., None]]:
77
+ return {
78
+ "connection_state_changed": self._connection,
79
+ "participant_connected": self._arrived,
80
+ "participant_disconnected": self._left,
81
+ "data_received": self._received,
82
+ }
83
+
84
+ def _connection(self, state: int) -> None:
85
+ if state == rtc.ConnectionState.CONN_CONNECTED:
86
+ for seat in self.room.remote_participants.values():
87
+ self._arrived(seat)
88
+
89
+ def _arrived(self, seat: rtc.RemoteParticipant) -> None:
90
+ if _a_widget(seat):
91
+ self._answer(self._greet(seat.identity))
92
+
93
+ def _left(self, seat: rtc.RemoteParticipant) -> None:
94
+ self.audience.pop(seat.identity, None)
95
+
96
+ # Flushed first, so the snapshot holds the widget's own arrival. What the tail sent past the
97
+ # snapshot is sent again to this widget alone: nothing skipped, nothing twice.
98
+ async def _greet(self, identity: str) -> None:
99
+ await self.call.writing.flushed(FLUSH_S)
100
+ state, last = await self.reading.state()
101
+ public = project_state(state, "public", self.call.config, identity)
102
+ await self._publish(SNAPSHOT, {"state": public, "last_seq": last}, identity)
103
+ if self.tailing is None:
104
+ self.cursor = last
105
+ self.tailing = asyncio.create_task(self._tail(last))
106
+ sent = last
107
+ while sent < self.cursor:
108
+ sent = await self._resend(identity, sent, self.cursor)
109
+ self.audience[identity] = sent
110
+
111
+ async def _tail(self, after: int) -> None:
112
+ async for entry in self.reading.tail(after):
113
+ self.cursor = entry.seq
114
+ for identity, sent in sorted(self.audience.items()):
115
+ if entry.seq > sent:
116
+ await self._send(entry, identity)
117
+ # A widget that left while this was sent is not added back.
118
+ if identity in self.audience:
119
+ self.audience[identity] = max(sent, entry.seq)
120
+
121
+ async def _resend(self, identity: str, after: int, until: int) -> int:
122
+ sent = after
123
+ async for entry in self.reading.since(after):
124
+ if entry.seq > until:
125
+ break
126
+ sent = entry.seq
127
+ await self._send(entry, identity)
128
+ return max(sent, until)
129
+
130
+ async def _send(self, entry: Entry, identity: str) -> None:
131
+ public = project_entry(entry, "public", self.call.config, identity)
132
+ if public is not None:
133
+ await self._publish(LOG, public, identity)
134
+
135
+ async def _publish(self, topic: str, payload: JsonObject, identity: str) -> None:
136
+ packed = json.dumps(payload, separators=(",", ":")).encode()
137
+ await self.room.local_participant.publish_data(
138
+ packed, topic=topic, destination_identities=[identity]
139
+ )
140
+
141
+ # Only a widget's seat speaks here. A replay ends with log.caught_up, as the SSE door's does;
142
+ # an event must be one the agent declared a participant may send.
143
+ def _received(self, packet: rtc.DataPacket) -> None:
144
+ sender = packet.participant
145
+ if sender is None or not _a_widget(sender):
146
+ return
147
+ data = _decoded(packet.data)
148
+ if packet.topic == REPLAY and isinstance(data.get("after"), int):
149
+ after = data["after"]
150
+ if isinstance(after, int):
151
+ self._answer(self._replay(sender.identity, after))
152
+ elif packet.topic == EVENT:
153
+ self._event(sender.identity, data)
154
+
155
+ async def _replay(self, identity: str, after: int) -> None:
156
+ sent = await self._resend(identity, after, self.cursor)
157
+ caught_up = Entry(
158
+ seq=sent,
159
+ ts=time.time(),
160
+ call=self.call.context.call,
161
+ agent=self.call.config.slug,
162
+ type="log.caught_up",
163
+ ephemeral=True,
164
+ data={"seq": sent},
165
+ )
166
+ await self._send(caught_up, identity)
167
+
168
+ def _event(self, identity: str, payload: JsonObject) -> None:
169
+ name, data = payload.get("name"), payload.get("data")
170
+ if not isinstance(name, str) or not isinstance(data, dict):
171
+ logger.warning("%s from a widget is not {name, data}; dropped", EVENT)
172
+ return
173
+ if not self.call.config.accepts(name, "participant"):
174
+ logger.warning("%s %r is not one a participant may send; dropped", EVENT, name)
175
+ return
176
+ received = EventReceived(name=name, data=data, source="participant", identity=identity)
177
+ self.call.writing.write("event.received", received)
178
+
179
+ # Runs off the room's synchronous listener: the gateway's refusal is a warning, never a
180
+ # task's exception nobody reads.
181
+ def _answer(self, answering: Coroutine[object, object, None]) -> None:
182
+ async def answered() -> None:
183
+ try:
184
+ await answering
185
+ except PinecallError:
186
+ logger.warning("the widget's channel could not read the log", exc_info=True)
187
+
188
+ task = asyncio.create_task(answered())
189
+ self.answering.add(task)
190
+ task.add_done_callback(self.answering.discard)
191
+
192
+
193
+ def _a_widget(seat: rtc.Participant) -> bool:
194
+ return seat.attributes.get(SCOPE_ATTRIBUTE, "") in READS_ITS_OWN_CALL
195
+
196
+
197
+ def _decoded(data: bytes) -> JsonObject:
198
+ try:
199
+ return _A_JSON_OBJECT.validate_json(data)
200
+ except ValidationError:
201
+ return {}
File without changes
@@ -0,0 +1,204 @@
1
+ """Admission: an org's limits in a world against what it used there, before it uses more."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from pinecall.domain.errors import QuotaExhausted
8
+ from pinecall.domain.names import Env
9
+ from pinecall.domain.org import QuotaName, Quotas
10
+ from pinecall.domain.scope import Scope
11
+ from pinecall.postgres.pool import Connection, Pool
12
+ from pinecall.process import box_settings
13
+ from pinecall.tenancy import usage
14
+
15
+ REFUSED = "the org has used {used} of its {limit} {quota} in the {env}"
16
+
17
+ # Dollars are the same in both worlds: the month's spend in both is held to the one budget.
18
+ BUDGET_REFUSED = "the org has spent {used} of its {limit} USD budget this month"
19
+
20
+
21
+ ADMISSION = "admission"
22
+
23
+
24
+ QUOTAS = """
25
+ SELECT minutes, messages, agents, concurrent_calls, memory_facts, knowledge_chunks, numbers, seats,
26
+ llm_tokens, hosted_apps, budget_usd, lends
27
+ FROM quotas WHERE org = %(org)s AND env = %(env)s
28
+ """
29
+
30
+
31
+ # Replaced whole: a limit left out stops being one.
32
+ SET_QUOTAS = """
33
+ INSERT INTO quotas (org, env, minutes, messages, agents, concurrent_calls, memory_facts,
34
+ knowledge_chunks, numbers, seats, llm_tokens, hosted_apps, budget_usd, lends)
35
+ VALUES (%(org)s, %(env)s, %(minutes)s, %(messages)s, %(agents)s, %(concurrent_calls)s,
36
+ %(memory_facts)s, %(knowledge_chunks)s, %(numbers)s, %(seats)s, %(llm_tokens)s,
37
+ %(hosted_apps)s, %(budget_usd)s, %(lends)s)
38
+ ON CONFLICT (org, env) DO UPDATE SET
39
+ minutes = excluded.minutes, messages = excluded.messages, agents = excluded.agents,
40
+ concurrent_calls = excluded.concurrent_calls, memory_facts = excluded.memory_facts,
41
+ knowledge_chunks = excluded.knowledge_chunks, numbers = excluded.numbers,
42
+ seats = excluded.seats, llm_tokens = excluded.llm_tokens,
43
+ hosted_apps = excluded.hosted_apps, budget_usd = excluded.budget_usd, lends = excluded.lends,
44
+ set_at = now()
45
+ """
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Ceiling:
50
+ """How long a call admitted may last: what is left of the org's minutes."""
51
+
52
+ seconds: int
53
+ minutes: int
54
+
55
+
56
+ class Admission(BaseModel):
57
+ """What a newborn org may use in each world: a person's first org, and any later one."""
58
+
59
+ model_config = ConfigDict(frozen=True, extra="forbid")
60
+
61
+ # A world the row does not name has no limits.
62
+ first: dict[Env, Quotas] = Field(default_factory=dict[Env, Quotas])
63
+ # None gives a later org what the first got; set, one trial per person.
64
+ later: dict[Env, Quotas] | None = None
65
+
66
+
67
+ # `at` is the log's clock, so a call is held to the month its summary will be counted in.
68
+ async def admit_call(pool: Pool, org: str, env: Env, *, running: int, at: float) -> Ceiling | None:
69
+ """Admit one more call, with its ceiling; None when the org's minutes have no limit."""
70
+ quotas = await quotas_of(pool, org, env)
71
+ _refuse_past(quotas, env, "concurrent_calls", running)
72
+ month = usage.month_of(at)
73
+ if quotas.budget_usd is not None:
74
+ _refuse_over_budget(quotas, await usage.spent_in(pool, org, month))
75
+ if quotas.minutes is None and quotas.messages is None and quotas.llm_tokens is None:
76
+ return None
77
+ spent = await usage.used(pool, org, env, month)
78
+ _refuse_past(quotas, env, "minutes", spent.minutes)
79
+ _refuse_past(quotas, env, "messages", spent.messages)
80
+ _refuse_past(quotas, env, "llm_tokens", spent.input_tokens + spent.output_tokens)
81
+ if quotas.minutes is None:
82
+ return None
83
+ # Never zero: the worker reads a zero ceiling as none.
84
+ seconds = max(1, int((quotas.minutes - spent.minutes) * 60))
85
+ return Ceiling(seconds=seconds, minutes=quotas.minutes)
86
+
87
+
88
+ # A call is counted at its summary, so a long written call adds what it has used so far.
89
+ async def admit_turn(pool: Pool, scope: Scope, *, turns: int, tokens: int, at: float) -> None:
90
+ """Admit one more turn of a written call already this long."""
91
+ org, env = scope.org, scope.env
92
+ quotas = await quotas_of(pool, org, env)
93
+ if quotas.messages is None and quotas.llm_tokens is None:
94
+ return
95
+ spent = await usage.used(pool, org, env, usage.month_of(at))
96
+ _refuse_past(quotas, env, "messages", spent.messages + turns)
97
+ _refuse_past(quotas, env, "llm_tokens", spent.input_tokens + spent.output_tokens + tokens)
98
+
99
+
100
+ async def admit_agent(pool: Pool, org: str, env: Env, *, holding: int) -> None:
101
+ """Admit one more agent held by the org's apps."""
102
+ _refuse_past(await quotas_of(pool, org, env), env, "agents", holding)
103
+
104
+
105
+ async def admit_number(pool: Pool, org: str, env: Env, *, bought: int) -> None:
106
+ """Admit one more number bought on the box's carrier account for the org."""
107
+ _refuse_past(await quotas_of(pool, org, env), env, "numbers", bought)
108
+
109
+
110
+ async def admit_memory(pool: Pool, org: str, env: Env, *, kept: int) -> None:
111
+ """Admit one more fact remembered about a contact."""
112
+ _refuse_past(await quotas_of(pool, org, env), env, "memory_facts", kept)
113
+
114
+
115
+ async def admit_seat(pool: Pool, org: str, env: Env, *, seated: int) -> None:
116
+ """Admit one more person seated in the org."""
117
+ _refuse_past(await quotas_of(pool, org, env), env, "seats", seated)
118
+
119
+
120
+ async def admit_hosted_app(pool: Pool, org: str, env: Env, *, hosting: int) -> None:
121
+ """Admit one more app the box hosts for the org."""
122
+ _refuse_past(await quotas_of(pool, org, env), env, "hosted_apps", hosting)
123
+
124
+
125
+ # Sized up front: a push that would pass the limit is refused whole.
126
+ async def admit_push(pool: Pool, org: str, env: Env, *, keeping: int) -> None:
127
+ """Admit a push that leaves the org keeping this many knowledge chunks."""
128
+ quotas = await quotas_of(pool, org, env)
129
+ limit = quotas.exceeded("knowledge_chunks", keeping)
130
+ if limit is not None:
131
+ _refused(env, "knowledge_chunks", keeping, limit)
132
+
133
+
134
+ async def quotas_of(pool: Pool, org: str, env: Env) -> Quotas:
135
+ """The org's limits in the world; none when nobody set them."""
136
+ async with pool.connection() as connection:
137
+ row = await (await connection.execute(QUOTAS, {"org": org, "env": env})).fetchone()
138
+ if row is None:
139
+ return Quotas()
140
+ lends = row.pop("lends")
141
+ return Quotas(**row, lends=None if lends is None else frozenset(lends))
142
+
143
+
144
+ async def set_quotas(pool: Pool, org: str, env: Env, quotas: Quotas) -> None:
145
+ """Replace the org's limits in the world, whole."""
146
+ async with pool.connection() as connection:
147
+ await _set_quotas(connection, org, env, quotas)
148
+
149
+
150
+ async def admission(pool: Pool) -> Admission:
151
+ """What a newborn org is given; nothing limited on a box that never said."""
152
+ async with pool.connection() as connection:
153
+ return await _admission(connection)
154
+
155
+
156
+ async def set_admission(pool: Pool, allowed: Admission) -> None:
157
+ """Write what a newborn org is given, whole, as the console's box screen sends it."""
158
+ async with pool.connection() as connection:
159
+ await box_settings.write(connection, ADMISSION, allowed.model_dump(mode="json"))
160
+
161
+
162
+ async def give_first_quotas(connection: Connection, org: str, *, already: int) -> None:
163
+ """Set a newborn org's quotas in each world, as the box's admission says for its person."""
164
+ allowed = await _admission(connection)
165
+ worlds = allowed.first if allowed.later is None or already == 0 else allowed.later
166
+ for env, quotas in worlds.items():
167
+ await _set_quotas(connection, org, env, quotas)
168
+
169
+
170
+ def _refuse_past(quotas: Quotas, env: Env, quota: QuotaName, spent: float) -> None:
171
+ limit = quotas.reached(quota, spent)
172
+ if limit is not None:
173
+ _refused(env, quota, spent, limit)
174
+
175
+
176
+ def _refused(env: Env, quota: QuotaName, spent: float, limit: int) -> None:
177
+ sentence = REFUSED.format(
178
+ used=_shown(spent), limit=limit, quota=quota.replace("_", " "), env=env
179
+ )
180
+ raise QuotaExhausted(sentence, quota=quota, used=spent, limit=limit)
181
+
182
+
183
+ def _refuse_over_budget(quotas: Quotas, spent: float) -> None:
184
+ limit = quotas.reached("budget_usd", spent)
185
+ if limit is not None:
186
+ sentence = BUDGET_REFUSED.format(used=_shown(spent), limit=limit)
187
+ raise QuotaExhausted(sentence, quota="budget_usd", used=spent, limit=limit)
188
+
189
+
190
+ def _shown(spent: float) -> int | float:
191
+ return int(spent) if float(spent).is_integer() else round(spent, 2)
192
+
193
+
194
+ async def _admission(connection: Connection) -> Admission:
195
+ value = await box_settings.read(connection, ADMISSION)
196
+ return Admission() if value is None else Admission.model_validate(value)
197
+
198
+
199
+ async def _set_quotas(connection: Connection, org: str, env: Env, quotas: Quotas) -> None:
200
+ lends = None if quotas.lends is None else sorted(quotas.lends)
201
+ await connection.execute(
202
+ SET_QUOTAS,
203
+ {"org": org, "env": env, **quotas.limits, "lends": lends},
204
+ )
@@ -0,0 +1,164 @@
1
+ """What an org sets per agent: how its widget looks, and its hold melody."""
2
+
3
+ import hashlib
4
+ import re
5
+ from dataclasses import asdict, dataclass
6
+ from typing import Literal
7
+
8
+ from pinecall.domain.errors import DeclarationRefused
9
+ from pinecall.domain.scope import Scope
10
+ from pinecall.postgres.pool import Pool
11
+ from pinecall.wire.parts import WidgetTheme
12
+
13
+ # No row is the box's own melody; `off` is silence; `custom` an uploaded clip, converted once.
14
+ type Played = Literal["off", "custom"]
15
+
16
+
17
+ LONGEST = {"title": 80, "tagline": 160, "greeting": 500, "accent": 40}
18
+
19
+
20
+ # The widget sets the accent as a CSS variable, so nothing that could end the declaration and
21
+ # open another passes.
22
+ A_COLOUR = re.compile(
23
+ r"^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+|(rgb|rgba|hsl|hsla|oklch)\([0-9.,%\s/a-z-]+\))$"
24
+ )
25
+
26
+
27
+ LOOK = """
28
+ SELECT title, tagline, greeting, accent, autostart, theme FROM agent_widgets
29
+ WHERE org = %(org)s AND env = %(env)s AND agent = %(agent)s
30
+ """
31
+
32
+
33
+ PUT_LOOK = """
34
+ INSERT INTO agent_widgets (org, env, agent, title, tagline, greeting, accent, autostart, theme)
35
+ VALUES (%(org)s, %(env)s, %(agent)s, %(title)s, %(tagline)s, %(greeting)s, %(accent)s,
36
+ %(autostart)s, %(theme)s)
37
+ ON CONFLICT (org, env, agent) DO UPDATE SET
38
+ title = excluded.title, tagline = excluded.tagline, greeting = excluded.greeting,
39
+ accent = excluded.accent, autostart = excluded.autostart, theme = excluded.theme,
40
+ set_at = now()
41
+ """
42
+
43
+
44
+ HOLD = """
45
+ SELECT played, sha256, seconds, name FROM hold_audio
46
+ WHERE org = %(org)s AND env = %(env)s AND agent = %(agent)s
47
+ """
48
+
49
+
50
+ HOLD_AUDIO = (
51
+ "SELECT audio FROM hold_audio WHERE org = %(org)s AND env = %(env)s AND agent = %(agent)s"
52
+ )
53
+
54
+
55
+ KEEP_HOLD = """
56
+ INSERT INTO hold_audio (org, env, agent, played, audio, sha256, seconds, name)
57
+ VALUES (%(org)s, %(env)s, %(agent)s, %(played)s, %(audio)s, %(sha256)s, %(seconds)s, %(name)s)
58
+ ON CONFLICT (org, env, agent) DO UPDATE SET
59
+ played = excluded.played, audio = excluded.audio, sha256 = excluded.sha256,
60
+ seconds = excluded.seconds, name = excluded.name, set_at = now()
61
+ """
62
+
63
+
64
+ FORGET_HOLD = "DELETE FROM hold_audio WHERE org = %(org)s AND env = %(env)s AND agent = %(agent)s"
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Look:
69
+ """How the widget presents the agent; None is the widget's own default."""
70
+
71
+ title: str | None = None
72
+ tagline: str | None = None
73
+ greeting: str | None = None
74
+ accent: str | None = None
75
+ autostart: bool = False
76
+ theme: WidgetTheme | None = None
77
+
78
+ def __post_init__(self) -> None:
79
+ text = {
80
+ "title": self.title,
81
+ "tagline": self.tagline,
82
+ "greeting": self.greeting,
83
+ "accent": self.accent,
84
+ }
85
+ for name, value in text.items():
86
+ longest = LONGEST[name]
87
+ if value is not None and len(value) > longest:
88
+ raise DeclarationRefused(f"a widget's {name} is {longest} characters at most")
89
+ if self.accent is not None and not A_COLOUR.match(self.accent):
90
+ raise DeclarationRefused(f"an accent is a CSS colour, not {self.accent!r}")
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class Chosen:
95
+ """What the agent plays while a tool runs: nothing, or a clip of its own."""
96
+
97
+ played: Played
98
+ sha256: str | None = None
99
+ seconds: float | None = None
100
+ name: str | None = None
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class Clip:
105
+ """An uploaded melody, already converted: its bytes, how long it lasts, the file's name."""
106
+
107
+ audio: bytes
108
+ seconds: float
109
+ name: str
110
+
111
+
112
+ async def look_of(pool: Pool, scope: Scope, agent: str) -> Look:
113
+ """How the agent's widget looks in the world; the widget's defaults when nobody said."""
114
+ async with pool.connection() as connection:
115
+ row = await (await connection.execute(LOOK, _of(scope, agent))).fetchone()
116
+ return Look() if row is None else Look(**row)
117
+
118
+
119
+ async def put_look(pool: Pool, scope: Scope, agent: str, look: Look) -> None:
120
+ """Replace how the agent's widget looks in the world."""
121
+ values = {**_of(scope, agent), **asdict(look)}
122
+ async with pool.connection() as connection:
123
+ await connection.execute(PUT_LOOK, values)
124
+
125
+
126
+ async def hold_of(pool: Pool, scope: Scope, agent: str) -> Chosen | None:
127
+ """What the agent plays while a tool runs in the world; None is the box's melody."""
128
+ async with pool.connection() as connection:
129
+ row = await (await connection.execute(HOLD, _of(scope, agent))).fetchone()
130
+ return None if row is None else Chosen(**row)
131
+
132
+
133
+ async def hold_audio(pool: Pool, scope: Scope, agent: str) -> bytes | None:
134
+ """The agent's own clip, when it plays one."""
135
+ async with pool.connection() as connection:
136
+ row = await (await connection.execute(HOLD_AUDIO, _of(scope, agent))).fetchone()
137
+ return None if row is None or row["audio"] is None else bytes(row["audio"])
138
+
139
+
140
+ async def keep_hold(pool: Pool, scope: Scope, agent: str, clip: Clip) -> Chosen:
141
+ """Play this clip while the agent's tools run in the world."""
142
+ chosen = Chosen("custom", hashlib.sha256(clip.audio).hexdigest(), clip.seconds, clip.name)
143
+ await _kept_hold(pool, {**_of(scope, agent), **asdict(chosen), "audio": clip.audio})
144
+ return chosen
145
+
146
+
147
+ async def silence_hold(pool: Pool, scope: Scope, agent: str) -> None:
148
+ """Play nothing while the agent's tools run; any clip is forgotten."""
149
+ await _kept_hold(pool, {**_of(scope, agent), **asdict(Chosen("off")), "audio": None})
150
+
151
+
152
+ async def forget_hold(pool: Pool, scope: Scope, agent: str) -> None:
153
+ """Play the box's own melody again; any clip is forgotten."""
154
+ async with pool.connection() as connection:
155
+ await connection.execute(FORGET_HOLD, _of(scope, agent))
156
+
157
+
158
+ async def _kept_hold(pool: Pool, values: dict[str, object]) -> None:
159
+ async with pool.connection() as connection:
160
+ await connection.execute(KEEP_HOLD, values)
161
+
162
+
163
+ def _of(scope: Scope, agent: str) -> dict[str, object]:
164
+ return {"org": scope.org, "env": scope.env, "agent": agent}