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,403 @@
1
+ """The knowledge and memory doors: bases and their files, a contact's facts, and the goldens."""
2
+
3
+ import time
4
+ from datetime import UTC, datetime
5
+ from typing import Annotated
6
+ from uuid import UUID
7
+
8
+ from fastapi import APIRouter, Depends, Path, Query
9
+
10
+ from pinecall.domain.agent import DEFAULT_CHUNKS_PER_TURN, KnowledgeFile
11
+ from pinecall.domain.errors import DeclarationRefused, NotFound
12
+ from pinecall.domain.scope import Scope
13
+ from pinecall.gateway._call_setup import keys_of, tuned
14
+ from pinecall.gateway._deps import (
15
+ GatewayDep,
16
+ KnowledgeKey,
17
+ MemoryKey,
18
+ ScopeDep,
19
+ TeamKey,
20
+ asked_by,
21
+ check_paced,
22
+ embedder_of,
23
+ )
24
+ from pinecall.gateway._gateway import Gateway
25
+ from pinecall.gateway._sockets import NO_AGENT
26
+ from pinecall.process.recordings import recordings_of
27
+ from pinecall.providers import catalog
28
+ from pinecall.providers.credentials import thinking
29
+ from pinecall.retrieval import extraction, knowledge, memory
30
+ from pinecall.retrieval.knowledge import Answered, Push, Question, SearchQuery
31
+ from pinecall.tenancy import admission, erasure, reads, scopes
32
+ from pinecall.tenancy.reads import Read
33
+ from pinecall.wire.rest.calls import Erasure
34
+ from pinecall.wire.rest.retrieval import (
35
+ AgentFact,
36
+ AgentMemory,
37
+ ContactFact,
38
+ ContactMemory,
39
+ ExtractionCases,
40
+ ExtractionRun,
41
+ Forgotten,
42
+ GoldenMiss,
43
+ KnowledgeBase,
44
+ KnowledgeFilePushed,
45
+ KnowledgeFilePut,
46
+ KnowledgeFileRead,
47
+ KnowledgeFileRow,
48
+ KnowledgeFiles,
49
+ KnowledgeGolden,
50
+ KnowledgeList,
51
+ KnowledgePush,
52
+ KnowledgePushed,
53
+ KnowledgeScore,
54
+ KnowledgeUse,
55
+ KnowledgeUses,
56
+ MemoryGolden,
57
+ MemoryScore,
58
+ OrgFact,
59
+ OrgMemory,
60
+ )
61
+
62
+ router = APIRouter()
63
+
64
+
65
+ # 404, not 204: dropping a mistyped name must not look like success.
66
+ NO_SUCH_BASE = "no knowledge base named {base}: nothing was pushed under that name"
67
+
68
+
69
+ NO_SUCH_FILE = "no file {path} in the base {base}"
70
+
71
+
72
+ # One sentence for a fact missing, already ended, or another org's: nothing is told apart.
73
+ NO_SUCH_FACT = "no current fact {id} in this key's org and world"
74
+
75
+
76
+ # A 400, not an empty pass: a green run for a feature the agent lacks would mislead.
77
+ KEEPS_NOTHING = (
78
+ "agent {slug} remembers nothing: its memory policy names nothing to keep"
79
+ " (pinecall memory policy --remember '…')"
80
+ )
81
+
82
+
83
+ # Each run is up to fifty model calls: a suite runs once, a loop is stopped.
84
+ RUNS_A_MINUTE = 6
85
+
86
+
87
+ TOO_MANY_RUNS = "more than six extraction runs this minute from this org: try again in a minute"
88
+
89
+
90
+ # The whole history, superseded facts included; a call reads the current ones through `recall`.
91
+ @router.get("/v1/contacts/{contact}/memory")
92
+ async def contact_memory(
93
+ contact: str, key: MemoryKey, where: ScopeDep, box: GatewayDep
94
+ ) -> ContactMemory:
95
+ """Every fact ever kept of the contact, current first."""
96
+ await reads.record(box.connections.pool, where, Read(contact, "memory", asked_by(key)))
97
+ return ContactMemory(
98
+ facts=[
99
+ ContactFact(
100
+ id=fact.id,
101
+ text=fact.text,
102
+ category=fact.category,
103
+ source=fact.taught_by,
104
+ valid_from=fact.valid_from.timestamp(),
105
+ invalidated_at=None
106
+ if fact.invalidated_at is None
107
+ else fact.invalidated_at.timestamp(),
108
+ )
109
+ for fact in await memory.history(box.connections.pool, where, contact)
110
+ ]
111
+ )
112
+
113
+
114
+ # Reading and erasing are the contact's rights: no quota ever refuses them.
115
+ @router.delete("/v1/contacts/{contact}/memory")
116
+ async def forget_contact(
117
+ contact: str, _key: MemoryKey, where: ScopeDep, box: GatewayDep
118
+ ) -> Forgotten:
119
+ """Every fact of the contact deleted, history included; zero is an answer, not a 404."""
120
+ return Forgotten(forgotten=await memory.forget(box.connections.pool, where, contact))
121
+
122
+
123
+ # A person's "delete my data": what they said on every call of the world, and what was kept.
124
+ @router.delete("/v1/contacts/{contact}")
125
+ async def erase_contact(contact: str, key: TeamKey, where: ScopeDep, box: GatewayDep) -> Erasure:
126
+ """Erase a contact in the world: every call they were on and every fact kept of them."""
127
+ recordings = recordings_of(box.connections.settings, box.connections.http)
128
+ erased = await erasure.contact(
129
+ box.connections.pool, recordings, where, contact, by=asked_by(key)
130
+ )
131
+ for call in erased.calls:
132
+ box.logs.forget(call)
133
+ return erased.trail
134
+
135
+
136
+ @router.post("/v1/contacts/memory/eval")
137
+ async def memory_eval(
138
+ body: MemoryGolden, _key: MemoryKey, where: ScopeDep, box: GatewayDep
139
+ ) -> MemoryScore:
140
+ """A memory golden asked of the ranking a call reads, on facts it writes and forgets."""
141
+ return await memory.ask_golden(
142
+ box.connections.pool, embedder_of(box), where, body, at=_now(box)
143
+ )
144
+
145
+
146
+ @router.get("/v1/agents/{slug}/memory")
147
+ async def agent_memory(
148
+ slug: str,
149
+ key: MemoryKey,
150
+ where: ScopeDep,
151
+ box: GatewayDep,
152
+ page: Annotated[memory.Paging, Query()],
153
+ ) -> AgentMemory:
154
+ """The current facts the agent's calls taught, across contacts, newest first, a page."""
155
+ await reads.record(box.connections.pool, where, Read(slug, "memory", asked_by(key)))
156
+ found = await memory.taught_by(box.connections.pool, where, slug, page=page)
157
+ return AgentMemory(
158
+ facts=[
159
+ AgentFact(
160
+ id=fact.id,
161
+ contact=fact.contact,
162
+ text=fact.text,
163
+ category=fact.category,
164
+ written_at=fact.valid_from.timestamp(),
165
+ )
166
+ for fact in found.facts
167
+ ],
168
+ next=found.next,
169
+ )
170
+
171
+
172
+ @router.get("/v1/memory")
173
+ async def org_memory(
174
+ key: MemoryKey, where: ScopeDep, box: GatewayDep, page: Annotated[memory.Paging, Query()]
175
+ ) -> OrgMemory:
176
+ """The current facts every agent's calls taught, each with its agent, newest first, a page."""
177
+ await reads.record(box.connections.pool, where, Read(where.org, "memory", asked_by(key)))
178
+ found = await memory.taught_by(box.connections.pool, where, None, page=page)
179
+ return OrgMemory(
180
+ facts=[
181
+ OrgFact(
182
+ id=fact.id,
183
+ agent=found.agents[fact.id],
184
+ contact=fact.contact,
185
+ text=fact.text,
186
+ category=fact.category,
187
+ written_at=fact.valid_from.timestamp(),
188
+ )
189
+ for fact in found.facts
190
+ ],
191
+ next=found.next,
192
+ )
193
+
194
+
195
+ # The row stays, ended: the history still shows it. Erasing a contact is the door above.
196
+ @router.delete("/v1/memory/facts/{id}")
197
+ async def forget_fact(
198
+ fact: Annotated[UUID, Path(alias="id")], _key: MemoryKey, where: ScopeDep, box: GatewayDep
199
+ ) -> Forgotten:
200
+ """One current fact ended from now on; 404 when there is none by that id."""
201
+ if not await memory.invalidated(box.connections.pool, where, str(fact), at=_now(box)):
202
+ raise NotFound(NO_SUCH_FACT.format(id=fact))
203
+ return Forgotten(forgotten=1)
204
+
205
+
206
+ # Here and not in the CLI: the agent's model, the org's keys and the tuned policy live here.
207
+ @router.post("/v1/agents/{slug}/memory/extraction")
208
+ async def memory_extraction(
209
+ slug: str, body: ExtractionCases, _key: MemoryKey, where: ScopeDep, box: GatewayDep
210
+ ) -> ExtractionRun:
211
+ """One hang-up per case on the agent's own model and keys, each answer judged by code."""
212
+ await check_paced(box, f"{where.org} memory/extraction", RUNS_A_MINUTE, TOO_MANY_RUNS)
213
+ registration = box.sockets.of(where, slug)
214
+ if registration is None or registration.scope.org != where.org:
215
+ raise NotFound(NO_AGENT.format(slug=slug))
216
+ configured = await catalog.providers(box.connections.pool)
217
+ config, _ = await tuned(box.connections.pool, registration.config, where, configured)
218
+ policy = config.memory
219
+ if policy is None or not policy.remember:
220
+ raise DeclarationRefused(KEEPS_NOTHING.format(slug=slug))
221
+ for case in body.cases:
222
+ extraction.check_case(case, policy)
223
+ model = thinking(
224
+ config, configured, await keys_of(box.connections.pool, box.connections.vault, where)
225
+ )
226
+ tools = tuple(tool.name for tool in config.tools)
227
+ started = time.perf_counter()
228
+ # One after the other: side by side would measure the vendor's concurrency.
229
+ results = [
230
+ await extraction.extract_case(model, case, policy=policy, tools=tools, at=_now(box))
231
+ for case in body.cases
232
+ ]
233
+ return ExtractionRun(
234
+ agent=slug,
235
+ model=model.vendor if model.model is None else f"{model.vendor}/{model.model}",
236
+ cases=len(results),
237
+ held=sum(item.held for item in results),
238
+ took_ms=(time.perf_counter() - started) * 1000,
239
+ results=results,
240
+ )
241
+
242
+
243
+ async def _knowledge_corner(_key: KnowledgeKey, where: ScopeDep) -> Scope:
244
+ return where
245
+
246
+
247
+ # The corner of a key that opens knowledge: the org and world from the key, the holder its own.
248
+ KnowledgeCorner = Annotated[Scope, Depends(_knowledge_corner)]
249
+
250
+
251
+ @router.put("/v1/knowledge/{base}")
252
+ async def push(
253
+ base: str, body: KnowledgePush, where: KnowledgeCorner, box: GatewayDep
254
+ ) -> KnowledgePushed:
255
+ """Replace the base with the folder: cut, embedded where it changed, indexed."""
256
+ started = time.perf_counter()
257
+ embedder = embedder_of(box)
258
+ files = tuple(knowledge.cut(KnowledgeFile(item.path, item.text)) for item in body.files)
259
+ folder = Push(base=base, files=files, at=box.logs.store.clock())
260
+ await _admitted(box, where, folder, path=None)
261
+ await knowledge.put(box.connections.pool, embedder, where, folder)
262
+ took_ms = (time.perf_counter() - started) * 1000
263
+ return KnowledgePushed(base=base, chunks=folder.chunks, took_ms=took_ms)
264
+
265
+
266
+ @router.get("/v1/knowledge")
267
+ async def bases(where: KnowledgeCorner, box: GatewayDep) -> KnowledgeList:
268
+ """Every base the key's corner reads, its size, its model and when it was pushed."""
269
+ return KnowledgeList(
270
+ bases=[
271
+ KnowledgeBase(
272
+ base=item.base, chunks=item.chunks, model=item.model, pushed_at=item.pushed_at
273
+ )
274
+ for item in await knowledge.bases(box.connections.pool, where)
275
+ ]
276
+ )
277
+
278
+
279
+ # Before `/{base}`, so "attached" is never read as a base's name.
280
+ @router.get("/v1/knowledge/attached")
281
+ async def attached(where: KnowledgeCorner, box: GatewayDep) -> KnowledgeUses:
282
+ """Each base some agent attaches, and the agents whose settings in the corner attach it."""
283
+ readers: dict[str, list[str]] = {}
284
+ for agent, tuning in sorted((await scopes.every_tuning(box.connections.pool, where)).items()):
285
+ for docs in tuning.bases or ():
286
+ readers.setdefault(docs.base, []).append(agent)
287
+ return KnowledgeUses(
288
+ bases=[KnowledgeUse(base=base, agents=agents) for base, agents in sorted(readers.items())]
289
+ )
290
+
291
+
292
+ @router.get("/v1/knowledge/{base}")
293
+ async def files(base: str, where: KnowledgeCorner, box: GatewayDep) -> KnowledgeFiles:
294
+ """The base's files, each with its size and chunks, never its text."""
295
+ listed = await knowledge.files(box.connections.pool, where, base)
296
+ if listed is None:
297
+ raise NotFound(NO_SUCH_BASE.format(base=base))
298
+ return KnowledgeFiles(
299
+ base=base,
300
+ kept=bool(listed),
301
+ files=[
302
+ KnowledgeFileRow(
303
+ path=item.path, chars=item.chars, chunks=item.chunks, pushed_at=item.pushed_at
304
+ )
305
+ for item in listed
306
+ ],
307
+ )
308
+
309
+
310
+ @router.get("/v1/knowledge/{base}/files/{path:path}")
311
+ async def read_file(
312
+ base: str, path: str, where: KnowledgeCorner, box: GatewayDep
313
+ ) -> KnowledgeFileRead:
314
+ """One file of the base, text and all."""
315
+ found = await knowledge.file(box.connections.pool, where, base, path)
316
+ if found is None:
317
+ raise NotFound(NO_SUCH_FILE.format(base=base, path=path))
318
+ return KnowledgeFileRead(
319
+ path=found.path, text=found.text or "", chunks=found.chunks, pushed_at=found.pushed_at
320
+ )
321
+
322
+
323
+ @router.put("/v1/knowledge/{base}/files/{path:path}")
324
+ async def put_file(
325
+ base: str, path: str, body: KnowledgeFilePut, where: KnowledgeCorner, box: GatewayDep
326
+ ) -> KnowledgeFilePushed:
327
+ """Put one file into the base, beginning the base when there is none; only it is cut."""
328
+ started = time.perf_counter()
329
+ embedder = embedder_of(box)
330
+ cut = knowledge.cut(KnowledgeFile(path, body.text))
331
+ folder = Push(base=base, files=(cut,), at=box.logs.store.clock())
332
+ await _admitted(box, where, folder, path=path)
333
+ await knowledge.put_file(box.connections.pool, embedder, where, folder)
334
+ took_ms = (time.perf_counter() - started) * 1000
335
+ return KnowledgeFilePushed(base=base, path=path, chunks=folder.chunks, took_ms=took_ms)
336
+
337
+
338
+ @router.delete("/v1/knowledge/{base}/files/{path:path}", status_code=204)
339
+ async def drop_file(base: str, path: str, where: KnowledgeCorner, box: GatewayDep) -> None:
340
+ """Take one file and its chunks out of the base, and the base with its last file."""
341
+ if not await knowledge.drop_file(box.connections.pool, where, base, path):
342
+ raise NotFound(NO_SUCH_FILE.format(base=base, path=path))
343
+
344
+
345
+ @router.delete("/v1/knowledge/{base}", status_code=204)
346
+ async def drop(base: str, where: KnowledgeCorner, box: GatewayDep) -> None:
347
+ """Drop the holder's own copy of the base and its chunks."""
348
+ if not await knowledge.drop(box.connections.pool, where, base):
349
+ raise NotFound(NO_SUCH_BASE.format(base=base))
350
+
351
+
352
+ # Sequential: searches at once would measure the pool, not the index. No floor: the golden
353
+ # measures the ranking, not an attachment's threshold.
354
+ @router.post("/v1/knowledge/{base}/eval")
355
+ async def evaluate(
356
+ base: str, body: KnowledgeGolden, where: KnowledgeCorner, box: GatewayDep
357
+ ) -> KnowledgeScore:
358
+ """Ask the golden's questions of the base and score recall@k and nDCG@10, with no model."""
359
+ embedder = embedder_of(box)
360
+ found = next(
361
+ (item for item in await knowledge.bases(box.connections.pool, where) if item.base == base),
362
+ None,
363
+ )
364
+ if found is None:
365
+ raise NotFound(NO_SUCH_BASE.format(base=base))
366
+ k = body.k or DEFAULT_CHUNKS_PER_TURN
367
+ started = time.perf_counter()
368
+ answered: list[Answered] = []
369
+ for item in body.questions:
370
+ query = SearchQuery(query=item.asks, k=k, bases={base: 0.0})
371
+ searched = await knowledge.search(box.connections.pool, embedder, where, query)
372
+ answered.append(Answered(Question(item.asks, item.expects), searched.found))
373
+ score = knowledge.score_docs(answered, k)
374
+ return KnowledgeScore(
375
+ base=base,
376
+ model=found.model,
377
+ questions=score.questions,
378
+ k=score.k,
379
+ recall_at_k=score.figures.recall_at_k,
380
+ ndcg_at_10=score.figures.ndcg_at_10,
381
+ took_ms=(time.perf_counter() - started) * 1000,
382
+ misses=[
383
+ GoldenMiss(
384
+ asks=miss.question.asks,
385
+ expects=miss.question.expects,
386
+ found=[knowledge.where(chunk) for chunk in miss.found],
387
+ )
388
+ for miss in score.misses
389
+ ],
390
+ )
391
+
392
+
393
+ # A push is sized whole before a row is written: what the org keeps in this world, less the
394
+ # copy it replaces, plus what it was cut into.
395
+ async def _admitted(box: Gateway, where: Scope, push: Push, *, path: str | None) -> None:
396
+ kept = await knowledge.kept(box.connections.pool, where.org, where.env)
397
+ replaced = await knowledge.chunks_kept(box.connections.pool, where, push.base, path=path)
398
+ keeping = kept - replaced + push.chunks
399
+ await admission.admit_push(box.connections.pool, where.org, where.env, keeping=keeping)
400
+
401
+
402
+ def _now(box: Gateway) -> datetime:
403
+ return datetime.fromtimestamp(box.logs.store.clock(), UTC)
@@ -0,0 +1,109 @@
1
+ """The runner's doors: the apps a world's runner is to have running, and what starts each."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from fastapi import APIRouter, Response
6
+
7
+ from pinecall.domain.errors import NotAvailable
8
+ from pinecall.domain.names import Env
9
+ from pinecall.gateway._deps import GatewayDep, RunnerKey
10
+ from pinecall.gateway._gateway import Gateway
11
+ from pinecall.postgres.pool import Pool
12
+ from pinecall.tenancy import hosted_running, hosting, org_secrets
13
+ from pinecall.tenancy.hosting import HostedApp
14
+ from pinecall.wire.rest.hosting import (
15
+ AppEnvironment,
16
+ RunnerHeartbeatRequest,
17
+ RunnerHeartbeatResponse,
18
+ RunnerReport,
19
+ WantedApp,
20
+ )
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ GZIP = "application/gzip"
26
+
27
+
28
+ NO_NAME = "this box has no PINECALL_DOMAIN: a hosted app has no gateway address to dial"
29
+
30
+
31
+ # In this order: what the runner says is kept first, so what it is answered already counts it.
32
+ @router.post("/v1/runner/heartbeat")
33
+ async def runner_heartbeat(
34
+ body: RunnerHeartbeatRequest, key: RunnerKey, gateway: GatewayDep
35
+ ) -> RunnerHeartbeatResponse:
36
+ """The runner's reports and logs kept, the time served counted, and what it is to run."""
37
+ pool = gateway.connections.pool
38
+ for report in body.reports:
39
+ await _kept(pool, HostedApp(org=report.org, env=key.env, name=report.name), report, body)
40
+ for logs in body.logs:
41
+ app = HostedApp(org=logs.org, env=key.env, name=logs.name)
42
+ await hosted_running.keep_logs(pool, app, host=logs.host, lines=logs.lines)
43
+ wanted = await hosting.hosted_in(pool, key.env)
44
+ hosts = {app.org: _hosts_of(gateway, app.org, key.env) for app in wanted}
45
+ serving = [
46
+ (app.org, app.name)
47
+ for app in wanted
48
+ if any(hosting.is_a_host_of(app.name, host) for host in hosts[app.org])
49
+ ]
50
+ await hosted_running.count_serving(pool, key.env, serving, datetime.now(UTC))
51
+ return RunnerHeartbeatResponse(
52
+ world=key.env,
53
+ apps=[
54
+ WantedApp(
55
+ org=app.org,
56
+ name=app.name,
57
+ release=app.release,
58
+ sha256=app.sha256,
59
+ host=app.host,
60
+ registered=app.host in hosts[app.org],
61
+ failed=app.failed,
62
+ logs_wanted=app.logs_wanted,
63
+ live_host=app.live_host,
64
+ )
65
+ for app in wanted
66
+ ],
67
+ )
68
+
69
+
70
+ @router.get("/v1/runner/apps/{org}/{name}/releases/{release}/source")
71
+ async def runner_source(
72
+ org: str, name: str, release: int, key: RunnerKey, gateway: GatewayDep
73
+ ) -> Response:
74
+ """The tarball of one release of any org's app in the runner's world."""
75
+ app = HostedApp(org=org, env=key.env, name=name)
76
+ source = await hosting.source_of(gateway.connections.pool, app, release)
77
+ return Response(source.data, media_type=GZIP)
78
+
79
+
80
+ # The box's own names win over an org's secret of the same name: the door refuses those names.
81
+ @router.get("/v1/runner/apps/{org}/{name}/environment")
82
+ async def runner_environment(
83
+ org: str, name: str, key: RunnerKey, gateway: GatewayDep
84
+ ) -> AppEnvironment:
85
+ """What the app's process is started with: the org's secrets, its token, the gateway."""
86
+ connections = gateway.connections
87
+ address = connections.settings.address
88
+ if address is None:
89
+ raise NotAvailable(NO_NAME)
90
+ app = HostedApp(org=org, env=key.env, name=name)
91
+ secrets = await org_secrets.environment_of(connections.pool, connections.vault, org, key.env)
92
+ token = await hosting.key_of(connections.pool, connections.vault, app)
93
+ return AppEnvironment(environment={**secrets, "PINECALL_KEY": token, "PINECALL_URL": address})
94
+
95
+
96
+ async def _kept(
97
+ pool: Pool, app: HostedApp, report: RunnerReport, body: RunnerHeartbeatRequest
98
+ ) -> None:
99
+ if report.state == "live":
100
+ await hosting.went_live(pool, app, report.host, runner=body.runner)
101
+ else:
102
+ await hosting.failed(pool, app, report.host, why=report.why, runner=body.runner)
103
+
104
+
105
+ # What each of the org's processes in the world says it runs on: an app serves while one of them
106
+ # is a host of its own, whichever release, and that time is what the org is billed for.
107
+ def _hosts_of(gateway: Gateway, org: str, env: Env) -> frozenset[str]:
108
+ processes = gateway.live.processes_of(org, env)
109
+ return frozenset(process.host for process in processes if process.host is not None)
@@ -0,0 +1,102 @@
1
+ """The lists of calls: an agent's newest and the org's, one row each, as the console draws them."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Query
6
+ from pydantic import BaseModel, Field
7
+
8
+ from pinecall.domain.errors import NotAllowed
9
+ from pinecall.gateway import _deps
10
+ from pinecall.gateway._deps import GatewayDep, Reader, ReaderDep
11
+ from pinecall.gateway._gateway import Gateway
12
+ from pinecall.log import lists, queries
13
+ from pinecall.log.readers import project_state
14
+ from pinecall.wire.rest.calls import CallList, CallRow, SessionScore
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ A_SCREENFUL = 20
20
+
21
+
22
+ LINE_FROM_STATE = (
23
+ "status",
24
+ "channel",
25
+ "direction",
26
+ "to",
27
+ "caller",
28
+ "started_at",
29
+ "ended_at",
30
+ "end_reason",
31
+ "outcome",
32
+ "cost",
33
+ "attention",
34
+ )
35
+
36
+
37
+ class ListQuery(BaseModel):
38
+ """What a list of calls asks for: an agent, words, a channel, a page below a call."""
39
+
40
+ limit: int = Field(A_SCREENFUL, ge=1, le=_deps.LONGEST_LIST)
41
+ q: str | None = Field(None, max_length=200)
42
+ agent: str | None = None
43
+ channel: str | None = None
44
+ before: str | None = None
45
+
46
+
47
+ @router.get("/v1/agents/{slug}/sessions")
48
+ async def list_agent_calls(
49
+ slug: str, reading: ReaderDep, gateway: GatewayDep, query: Annotated[ListQuery, Query()]
50
+ ) -> CallList:
51
+ """The agent's newest calls, one row each."""
52
+ wanted = lists.ListFilters(
53
+ agent=slug, q=query.q or None, channel=query.channel, before=query.before
54
+ )
55
+ return await _sessions(gateway, reading, wanted, query.limit)
56
+
57
+
58
+ @router.get("/v1/sessions")
59
+ async def list_calls(
60
+ reading: ReaderDep, gateway: GatewayDep, query: Annotated[ListQuery, Query()]
61
+ ) -> CallList:
62
+ """The org's newest calls across its agents, one row each."""
63
+ wanted = lists.ListFilters(
64
+ agent=query.agent or None, q=query.q or None, channel=query.channel, before=query.before
65
+ )
66
+ return await _sessions(gateway, reading, wanted, query.limit)
67
+
68
+
69
+ async def _sessions(
70
+ gateway: Gateway, reading: Reader, wanted: lists.ListFilters, limit: int
71
+ ) -> CallList:
72
+ if reading.acting is None or reading.scope is None:
73
+ raise NotAllowed("a list of calls is read with a key, not a call's token")
74
+ found = await lists.found(gateway.connections.pool, reading.scope, wanted, limit=limit)
75
+ facts = await queries.facts_of_calls(gateway.connections.pool, found.calls)
76
+ lines: list[CallRow] = []
77
+ for call in found.calls:
78
+ state = await gateway.logs.reading(call).snapshot()
79
+ if state.seq == 0:
80
+ continue
81
+ text = project_state(state, "tenant", gateway.sockets.declared(state.agent or ""))
82
+ fact = facts.get(call)
83
+ score = None
84
+ if fact is not None and fact.judged is not None and fact.passed is not None:
85
+ score = SessionScore(
86
+ held=fact.held or 0, judged=fact.judged, passed=fact.passed, reason=fact.reason
87
+ )
88
+ lines.append(
89
+ CallRow.model_validate(
90
+ {
91
+ **{name: text.get(name) for name in LINE_FROM_STATE},
92
+ "from": text.get("from"),
93
+ "call": call,
94
+ "agent": state.agent or "",
95
+ "last_seq": state.seq,
96
+ "live": not await gateway.logs.store.sealed(call),
97
+ "score": None if score is None else score.written(),
98
+ "flags": [] if fact is None else fact.flags,
99
+ }
100
+ )
101
+ )
102
+ return CallList(calls=lines, total=found.total, next=found.next)