solvent-agent 0.2.2__tar.gz → 0.3.0__tar.gz

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 (170) hide show
  1. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/PKG-INFO +230 -4
  2. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/README.md +219 -3
  3. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/pyproject.toml +9 -4
  4. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/__init__.py +1 -1
  5. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/__main__.py +12 -0
  6. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/agent.py +3 -0
  7. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/alerts.py +14 -3
  8. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/checkout.py +86 -17
  9. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/cli.py +14 -8
  10. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/dashboard.py +100 -0
  11. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/delivery.py +57 -31
  12. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/doctor.py +38 -0
  13. solvent_agent-0.3.0/solvent/funnel.py +359 -0
  14. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/gateway.py +19 -9
  15. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/guardrail_cmd.py +18 -0
  16. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/guardrails.py +197 -11
  17. solvent_agent-0.3.0/solvent/llm_backend.py +133 -0
  18. solvent_agent-0.3.0/solvent/nano_client.py +633 -0
  19. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/nemotron.py +11 -1
  20. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/onboarding.py +26 -4
  21. solvent_agent-0.3.0/solvent/ops.py +246 -0
  22. solvent_agent-0.3.0/solvent/pdf.py +249 -0
  23. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/pricing.py +15 -5
  24. solvent_agent-0.3.0/solvent/products.py +398 -0
  25. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/quote_cmd.py +22 -5
  26. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/server.py +94 -2
  27. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/service.py +16 -1
  28. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/simulate.py +16 -4
  29. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/stages.py +182 -22
  30. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/treasury.py +134 -8
  31. solvent_agent-0.3.0/solvent/turnaround.py +234 -0
  32. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/worker.py +4 -3
  33. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent_agent.egg-info/PKG-INFO +230 -4
  34. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent_agent.egg-info/SOURCES.txt +18 -0
  35. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent_agent.egg-info/requires.txt +14 -0
  36. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_checkout.py +57 -1
  37. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_cli_routing.py +12 -0
  38. solvent_agent-0.3.0/tests/test_cli_summary.py +48 -0
  39. solvent_agent-0.3.0/tests/test_dashboard_commercial.py +101 -0
  40. solvent_agent-0.3.0/tests/test_funnel.py +200 -0
  41. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_guardrails.py +6 -4
  42. solvent_agent-0.3.0/tests/test_job_budget_approval.py +276 -0
  43. solvent_agent-0.3.0/tests/test_llm_backend.py +215 -0
  44. solvent_agent-0.3.0/tests/test_malformed_policy.py +59 -0
  45. solvent_agent-0.3.0/tests/test_nano_client.py +492 -0
  46. solvent_agent-0.3.0/tests/test_ops.py +214 -0
  47. solvent_agent-0.3.0/tests/test_pdf.py +265 -0
  48. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_pricing.py +33 -0
  49. solvent_agent-0.3.0/tests/test_products.py +295 -0
  50. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_route_security.py +1 -0
  51. solvent_agent-0.3.0/tests/test_supply_chain.py +40 -0
  52. solvent_agent-0.3.0/tests/test_turnaround.py +192 -0
  53. solvent_agent-0.3.0/tests/test_wizard_no_tty.py +46 -0
  54. solvent_agent-0.2.2/solvent/products.py +0 -209
  55. solvent_agent-0.2.2/tests/test_products.py +0 -129
  56. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/LICENSE +0 -0
  57. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/setup.cfg +0 -0
  58. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/_filelock.py +0 -0
  59. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/access.py +0 -0
  60. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/backlog.py +0 -0
  61. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/calibration.py +0 -0
  62. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/capacity.py +0 -0
  63. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/channels/__init__.py +0 -0
  64. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/channels/telegram.py +0 -0
  65. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/chat.py +0 -0
  66. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/config.py +0 -0
  67. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/config_cmd.py +0 -0
  68. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/customers.py +0 -0
  69. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/dashboard_chat.py +0 -0
  70. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/event_hub.py +0 -0
  71. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/export.py +0 -0
  72. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/finance.py +0 -0
  73. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/init.py +0 -0
  74. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/intake.py +0 -0
  75. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/job_cmd.py +0 -0
  76. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/jobs.py +0 -0
  77. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/logs.py +0 -0
  78. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/memory.py +0 -0
  79. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/money.py +0 -0
  80. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/notifications.py +0 -0
  81. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/observability.py +0 -0
  82. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/optimize.py +0 -0
  83. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/pairing.py +0 -0
  84. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/paths.py +0 -0
  85. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/py.typed +0 -0
  86. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/qr.py +0 -0
  87. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/quality.py +0 -0
  88. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/queue.py +0 -0
  89. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/rate_limit.py +0 -0
  90. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/receipt.py +0 -0
  91. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/reconcile.py +0 -0
  92. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/review.py +0 -0
  93. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/security.py +0 -0
  94. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/status.py +0 -0
  95. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/stripe_client.py +0 -0
  96. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/templates/workspace/AGENTS.md +0 -0
  97. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/templates/workspace/BRAIN.md +0 -0
  98. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/templates/workspace/SOUL.md +0 -0
  99. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/templates/workspace/skills/commission-research/SKILL.md +0 -0
  100. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/tools.py +0 -0
  101. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/upgrade.py +0 -0
  102. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/webhook_log.py +0 -0
  103. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent/workspace.py +0 -0
  104. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent_agent.egg-info/dependency_links.txt +0 -0
  105. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent_agent.egg-info/entry_points.txt +0 -0
  106. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/solvent_agent.egg-info/top_level.txt +0 -0
  107. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_agent.py +0 -0
  108. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_alerts.py +0 -0
  109. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_backlog.py +0 -0
  110. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_calibration.py +0 -0
  111. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_capacity.py +0 -0
  112. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_chat_tools.py +0 -0
  113. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_cogs_reconciliation.py +0 -0
  114. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_config.py +0 -0
  115. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_config_cmd.py +0 -0
  116. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_counter_offer.py +0 -0
  117. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_customers.py +0 -0
  118. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_dashboard_chat.py +0 -0
  119. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_dashboard_financials.py +0 -0
  120. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_dashboard_ops_xss.py +0 -0
  121. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_dashboard_security.py +0 -0
  122. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_delivery.py +0 -0
  123. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_doctor.py +0 -0
  124. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_event_hub.py +0 -0
  125. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_export.py +0 -0
  126. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_filelock.py +0 -0
  127. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_finance.py +0 -0
  128. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_gateway.py +0 -0
  129. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_init.py +0 -0
  130. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_intake.py +0 -0
  131. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_job_cmd.py +0 -0
  132. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_jobs.py +0 -0
  133. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_logs.py +0 -0
  134. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_memory.py +0 -0
  135. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_metrics_block.py +0 -0
  136. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_money.py +0 -0
  137. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_nemotron_parse.py +0 -0
  138. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_notifications.py +0 -0
  139. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_observability.py +0 -0
  140. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_onboarding.py +0 -0
  141. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_optimize.py +0 -0
  142. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_packaging.py +0 -0
  143. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_paths.py +0 -0
  144. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_qr.py +0 -0
  145. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_quality.py +0 -0
  146. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_queue.py +0 -0
  147. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_rate_limit.py +0 -0
  148. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_receipt.py +0 -0
  149. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_reconcile.py +0 -0
  150. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_research_brief.py +0 -0
  151. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_retry.py +0 -0
  152. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_review.py +0 -0
  153. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_runtime_config_paths.py +0 -0
  154. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_security.py +0 -0
  155. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_server.py +0 -0
  156. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_server_job_submission.py +0 -0
  157. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_simulate.py +0 -0
  158. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_spend_policy.py +0 -0
  159. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_stages.py +0 -0
  160. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_status.py +0 -0
  161. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_stripe_client.py +0 -0
  162. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_telegram_pairing.py +0 -0
  163. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_tools.py +0 -0
  164. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_treasury.py +0 -0
  165. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_upgrade.py +0 -0
  166. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_upgrade_hint.py +0 -0
  167. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_webhook_log.py +0 -0
  168. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_webhook_security.py +0 -0
  169. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_worker.py +0 -0
  170. {solvent_agent-0.2.2 → solvent_agent-0.3.0}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solvent-agent
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: A self-funding analyst agent: earns on Stripe, fulfils with NVIDIA Nemotron, spends within guardrails.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/ianalloway/solvent-agent
@@ -21,13 +21,19 @@ Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Provides-Extra: stripe
23
23
  Requires-Dist: stripe>=9.0.0; extra == "stripe"
24
+ Requires-Dist: urllib3>=2.8.0; extra == "stripe"
25
+ Requires-Dist: certifi>=2026.10.19; extra == "stripe"
24
26
  Provides-Extra: serve
25
27
  Requires-Dist: fastapi>=0.110.0; extra == "serve"
26
28
  Requires-Dist: uvicorn[standard]>=0.27.0; extra == "serve"
29
+ Requires-Dist: anyio>=4.14.2; extra == "serve"
27
30
  Provides-Extra: qr
28
31
  Requires-Dist: qrcode[pil]>=7.0; extra == "qr"
32
+ Requires-Dist: pillow>=13.0.0; python_version >= "3.11" and extra == "qr"
29
33
  Provides-Extra: telegram
30
34
  Requires-Dist: python-telegram-bot>=21.0; extra == "telegram"
35
+ Requires-Dist: anyio>=4.14.2; extra == "telegram"
36
+ Requires-Dist: certifi>=2026.10.19; extra == "telegram"
31
37
  Provides-Extra: dev
32
38
  Requires-Dist: pytest>=7.0.0; extra == "dev"
33
39
  Requires-Dist: ruff>=0.3; extra == "dev"
@@ -37,6 +43,10 @@ Requires-Dist: fastapi>=0.110.0; extra == "all"
37
43
  Requires-Dist: uvicorn[standard]>=0.27.0; extra == "all"
38
44
  Requires-Dist: qrcode[pil]>=7.0; extra == "all"
39
45
  Requires-Dist: python-telegram-bot>=21.0; extra == "all"
46
+ Requires-Dist: urllib3>=2.8.0; extra == "all"
47
+ Requires-Dist: certifi>=2026.10.19; extra == "all"
48
+ Requires-Dist: anyio>=4.14.2; extra == "all"
49
+ Requires-Dist: pillow>=13.0.0; python_version >= "3.11" and extra == "all"
40
50
  Dynamic: license-file
41
51
 
42
52
  <div align="center">
@@ -47,6 +57,7 @@ Dynamic: license-file
47
57
 
48
58
  [![CI](https://github.com/ianalloway/solvent-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/ianalloway/solvent-agent/actions/workflows/ci.yml)
49
59
  [![PyPI](https://img.shields.io/pypi/v/solvent-agent.svg)](https://pypi.org/project/solvent-agent/)
60
+ [![Hugging Face Space](https://img.shields.io/badge/%F0%9F%A4%97%20Space-try%20in%20browser-yellow)](https://huggingface.co/spaces/ianalloway/solvent-agent-demo)
50
61
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue?logo=python&logoColor=white)](https://www.python.org/downloads/)
51
62
  [![Zero dependencies](https://img.shields.io/badge/core%20deps-0-brightgreen)](pyproject.toml)
52
63
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
@@ -59,6 +70,11 @@ Dynamic: license-file
59
70
 
60
71
  <sub>Recorded from the real CLI with no API keys and no network access: the Stripe checkout links and Nemotron output are the built-in offline stubs, and the dollar figures are simulated.</sub>
61
72
 
73
+ **[▶ Try it in your browser](https://huggingface.co/spaces/ianalloway/solvent-agent-demo)**: the real package running in Pyodide, with no install and no keys.
74
+
75
+ [![The SOLVENT Hugging Face Space: five jobs run, J3 declined with a $15 counter-offer, and a simulated P&L snapshot](docs/hf-space-demo.png)](https://huggingface.co/spaces/ianalloway/solvent-agent-demo)
76
+
77
+ <sub>All dollar figures, including the final cash balance, are simulated.</sub>
62
78
  </div>
63
79
 
64
80
  > **Demo by default.** With no keys set, `solvent` runs an **offline, zero-key simulation** of the whole loop. Dollar figures in the demo are illustrative, **not production revenue**. Stripe test-mode Payment Links and live NVIDIA Nemotron are opt-in; see [Make It Real](#-make-it-real).
@@ -196,7 +212,10 @@ A typical **offline demo** batch (illustrative numbers from the simulated run
196
212
  BOOK P&L ──▶ treasury updated · dashboard refreshed
197
213
  ```
198
214
 
199
- Revenue is **always collected before cost is incurred**, and no payment can violate policy. The business is safe by construction and profitable by rule.
215
+ The job pipeline checks payment before fulfilment and screens vendor payments
216
+ against the configured spend policy. Quotes use estimated costs, so an accepted
217
+ quote is a projected margin, not a guarantee of realized profit. The ledger and
218
+ cost-calibration reports show how actual spending compares with that estimate.
200
219
 
201
220
  ---
202
221
 
@@ -218,6 +237,11 @@ Revenue is **always collected before cost is incurred**, and no payment can viol
218
237
  - **Offline-first** — without API keys the demo runs on deterministic stubs. Add `NVIDIA_API_KEY` + `STRIPE_API_KEY=sk_test_...` to unlock live inference and real Payment Links.
219
238
  - **Audit trail** — every `cs_...` checkout session ID and `pi_...` PaymentIntent ID is recorded on the ledger before fulfilment begins.
220
239
 
240
+ Resource prices can be overridden in `.solvent/pricing_overrides.json`. Values
241
+ are costs in cents and must be finite, non-negative numbers; `0` is valid for a
242
+ free resource. Invalid entries (including negative values, booleans, `NaN`, and
243
+ infinity) keep the built-in price. Valid entries in the same file still apply.
244
+
221
245
  ---
222
246
 
223
247
  ## 🎮 Running Modes
@@ -295,6 +319,8 @@ python3 -m solvent export --since 30d # ledger/jobs/metrics/customers + peri
295
319
  python3 -m solvent products # the price list, checked against current cost
296
320
  python3 -m solvent quality # scores the quality gate gave shipped briefs
297
321
  python3 -m solvent review # approve/reject the jobs intake held for a human
322
+ python3 -m solvent funnel --since 7d # win rate: submitted → quoted → paid → delivered
323
+ python3 -m solvent turnaround # measured paid-to-delivered time, and the promise
298
324
  python3 -m solvent reconcile --since 7d # Stripe ↔ ledger drift check
299
325
  python3 -m solvent finance # income statement, unit economics, runway
300
326
  python3 -m solvent finance --json # machine-readable report
@@ -376,6 +402,65 @@ directory, move them into this resolved directory before upgrading.
376
402
  rolling window is used, per-vendor exposure against its cap, and every spend
377
403
  the policy blocked.
378
404
 
405
+ ### Spending controls — per-job envelopes and human approval
406
+
407
+ Two more rules sit on top of the daily, per-vendor and velocity limits. Both
408
+ run inside the same deterministic guardrail check, before any Stripe call, and
409
+ both fail closed.
410
+
411
+ | Rule | Decision rule id | What happens |
412
+ |---|---|---|
413
+ | **Per-job budget envelope** | `job_budget` | Every job has a hard spend cap in integer cents (`job_budget_cents`, default 15000 = $150; `0` turns it off). Spend up to *exactly* the cap is allowed; anything that would go past it is refused with "job '…' budget exhausted". Refunds don't count against it. |
414
+ | **Human-approval hook** | `approval_pending` / `approval_denied` | A spend *strictly above* `approval_threshold_cents` is not paid. It goes to a pluggable approver, which answers `"approved"`, `"denied"` or `"held"`. The default approver always holds. An approver that raises an error or returns anything else also holds. Approval cannot override a hard rule such as the job envelope. |
415
+
416
+ Configure them in `.solvent/spend_policy.json`:
417
+
418
+ ```json
419
+ { "job_budget_cents": 8000, "approval_threshold_cents": 2500 }
420
+ ```
421
+
422
+ or with the environment variables `SOLVENT_JOB_BUDGET_CENTS` and
423
+ `SOLVENT_APPROVAL_THRESHOLD_CENTS`. Env vars can only **tighten** these
424
+ settings: a lower budget or a lower threshold. An env value that would raise
425
+ or disable a limit (for example `SOLVENT_JOB_BUDGET_CENTS=0`) is ignored.
426
+ Loosening or disabling takes an explicit policy-file setting, and any override
427
+ that loosens or disables a limit, or is ignored, is logged as a warning and
428
+ shown in `solvent guardrails`. The approval hook is off unless a threshold is
429
+ set.
430
+
431
+ A spend evaluated without a job id is not exempt from the budget. All unscoped
432
+ spends share one `unscoped` envelope under the same cap.
433
+
434
+ Wire in your own approver (a Slack prompt, a ticket queue, a CLI confirm):
435
+
436
+ ```python
437
+ from solvent.guardrails import ApprovalRequest, Guardrails
438
+
439
+ def approver(req: ApprovalRequest) -> str:
440
+ return "approved" if req.vendor == "market-data-api" else "held"
441
+
442
+ guard = Guardrails(treasury, approver=approver)
443
+ ```
444
+
445
+ A held spend stops the job and emits a `spend_held` event, so nothing moves
446
+ until a human decides.
447
+
448
+ **How this relates to the card networks.** The pattern is close to what the
449
+ networks describe publicly. Mastercard's
450
+ [Agent Pay for Machines](https://www.mastercard.com/global/en/news-and-trends/press/2026/october/Mastercard-Agent-Pay-for-Machines-in-action.html)
451
+ gives an agent "a job and a spending limit", and Mastercard's
452
+ [agentic token framework](https://www.mastercard.com/us/en/news-and-trends/stories/2025/agentic-commerce-framework.html)
453
+ carries transaction limits and validity windows. In the
454
+ [Visa, BILL and Billtrust pilot](https://corporate.visa.com/en/sites/visa-perspectives/newsroom/visa-bill-billtrust-launch-pilot.html),
455
+ the agent only pays invoices the buyer has already approved. Stripe's
456
+ [Issuing for agents](https://docs.stripe.com/issuing/agents) offers per-card and
457
+ per-cardholder
458
+ [spending controls](https://docs.stripe.com/issuing/controls/spending-controls)
459
+ and real-time authorization webhooks. Those controls run at the card or network
460
+ layer. Solvent's run inside the agent, before it calls any payment API, so they
461
+ work in simulation and with any payment backend. They complement network-level
462
+ controls and don't replace them.
463
+
379
464
  ### Backlog — which job to work on next
380
465
 
381
466
  A queue is not a plan. When several jobs are open and both cash and the 24h
@@ -524,6 +609,102 @@ a single JSON document, over a period given as `7d`, `24h`, `2w`, `3m` or
524
609
  revenue and cost counted when the money moved — so the printed summary ties out
525
610
  against the rows it ships, with refunds separated from cost of sales.
526
611
 
612
+ ### How long it takes
613
+
614
+ "When will I get it?" is the second question every customer asks, and the agent
615
+ had no answer. Fulfilment seconds were recorded, but that is only the model's
616
+ part — what the customer experiences is the wait from *paying* to *having the
617
+ brief*, across the queue, the worker's poll, retries and delivery.
618
+
619
+ That wait is already on the record (the ledger says when the money arrived, the
620
+ event log says when the brief went out), so `solvent turnaround` measures it
621
+ rather than estimating, and turns the distribution into something quotable: a
622
+ promise at a chosen percentile, rounded up, never faster than a floor. The
623
+ promise then appears on accepted quotes:
624
+
625
+ ```
626
+ Verdict ACCEPT — accepted
627
+ Turnaround within 5 minutes
628
+ ```
629
+
630
+ It also retires an arbitrary threshold. `alerts.py` called a paid job "stuck"
631
+ after two hours because two hours sounded reasonable; a shop that normally
632
+ delivers in forty seconds should not wait two hours to notice, and one that
633
+ takes a day should not cry wolf. With enough history, late is defined by the
634
+ shop's own p95 — and only until then by the fixed hours.
635
+
636
+ ### Running it from Telegram
637
+
638
+ Fifteen commands' worth of reporting lived in the CLI, which is where the
639
+ operator is not. The gateway's slash commands now cover the business too —
640
+ `/funnel`, `/customers`, `/capacity`, `/alerts`, `/products`, `/quality`,
641
+ `/review`, `/costs` — each a short read over local state, no model call, no
642
+ spend.
643
+
644
+ The catch is who is asking. Any *paired* Telegram user reaches that handler,
645
+ and a customer is not entitled to the agent's win rate or cash position, so
646
+ these are operator-only:
647
+
648
+ - the **dashboard** channel is the operator by construction — it sits behind
649
+ `SOLVENT_DASHBOARD_TOKEN`;
650
+ - a Telegram id in `SOLVENT_OPERATOR_IDS` is the operator;
651
+ - anyone else gets "That command is operator-only", and `/help` does not list
652
+ commands they cannot run.
653
+
654
+ ### The deliverable
655
+
656
+ Every job has been billed `pdf-render-saas` since the first commit, and no PDF
657
+ was ever produced — the agent was paying itself for a deliverable it did not
658
+ deliver. It renders one now: markdown, HTML **and** PDF, written beside each
659
+ other in `data/reports/`, linked from the delivery email and served from the
660
+ same token-gated route as the brief itself (`&format=pdf`), so nothing new is
661
+ exposed. With SMTP configured the PDF is attached to the email.
662
+
663
+ The renderer is `solvent/pdf.py`, about 250 lines of stdlib: a PDF is a text
664
+ container with a byte-offset table, so a single-font document needs no library,
665
+ only exact xref offsets. Base-14 Helvetica means nothing is embedded and a
666
+ brief weighs a couple of kilobytes. A render failure is caught and logged
667
+ rather than raised — losing a brief the customer has paid for would be far
668
+ worse than shipping it without the PDF.
669
+
670
+ ### Knowing the win rate
671
+
672
+ **`solvent funnel`.** The treasury has always known the margin on the jobs it
673
+ took; it never knew the win rate. Those are different businesses — a 90% margin
674
+ on one job in ten is a worse shop than a 60% margin on eight, and nothing here
675
+ could tell them apart.
676
+
677
+ Every stage already leaves a trace, so the funnel is a read over data the agent
678
+ keeps anyway: submitted → quoted → accepted → checkout → paid → delivered. What
679
+ falls out at each step is the point, and the three failures want three
680
+ different fixes:
681
+
682
+ | Exit | What it means |
683
+ |---|---|
684
+ | `screened` | held before pricing — cost nothing |
685
+ | `declined` | a customer who wanted to pay and was turned away by the margin gate |
686
+ | `abandoned` | a customer who said yes at checkout and then did not pay |
687
+ | `refunded` | paid, then refunded when fulfilment failed — the expensive loss |
688
+
689
+ Lost pipeline is reported at the price the customer named, because that is the
690
+ money that was on the table. Win rate is broken down by product and price band,
691
+ and counter-offers are tracked separately, so "does the counter-offer actually
692
+ convert?" finally has an answer.
693
+
694
+ ```
695
+ Submitted 9 ████████████████████████ 100.0%
696
+ Accepted by the gate 6 ████████████████········ 66.7%
697
+ Paid 5 █████████████··········· 55.6%
698
+
699
+ Gate acceptance 85.7% · payment conversion 83.3%
700
+ Won $373.00 · left on the table $9,130.00
701
+ Counter-offers 1 made, 0 won (0.0%)
702
+ ```
703
+
704
+ The same picture now has a **Commercial** panel in the HTML dashboard — win
705
+ rate, customer book, brief quality and the throughput ceiling beside the P&L —
706
+ so the business reads in one place instead of across six commands.
707
+
527
708
  ### Selling, not just quoting
528
709
 
529
710
  **`solvent products` — a price list.** A shop that cannot say what it charges
@@ -538,8 +719,29 @@ curl -X POST localhost:8787/jobs -d '{"topic": "...", "product": "deep-dive", ..
538
719
 
539
720
  The catalogue is priced against *current* costs — calibration included — so a
540
721
  list price that has quietly stopped clearing the margin floor shows up as a
541
- stale price rather than as a run of unprofitable work. The command exits
542
- non-zero when any product has gone stale, so cron can catch it.
722
+ stale price rather than as a run of unprofitable work.
723
+
724
+ Changing what the shop sells is a business decision, not a code change, so the
725
+ built-in list is overridable in `.solvent/products.json` beside the spend and
726
+ intake policies. Entries merge by slug — naming only a price keeps the built-in
727
+ scope — `retire` drops a product, and `replace: true` makes the file the whole
728
+ list:
729
+
730
+ ```json
731
+ {
732
+ "products": [
733
+ { "slug": "express", "list_price_cents": 3500 },
734
+ { "slug": "retainer", "name": "Monthly retainer", "list_price_cents": 49900,
735
+ "est_tokens": 60000, "market_data_calls": 12, "web_search_calls": 40 }
736
+ ],
737
+ "retire": ["deep-dive"]
738
+ }
739
+ ```
740
+
741
+ A malformed entry is dropped rather than sold — a half-read product is a price
742
+ nobody agreed to — and `solvent products --validate` reports it. The command
743
+ exits non-zero when any product has gone stale *or* the file is broken, so cron
744
+ catches either without reading the output.
543
745
 
544
746
  **`solvent quality` — a gate on the product, not just the margin.** Every other
545
747
  gate here asks whether the money is sound; this one asks whether the brief is.
@@ -595,6 +797,22 @@ With both keys set:
595
797
  - Optional: set `STRIPE_WEBHOOK_SECRET` and forward `checkout.session.completed` events via `StripeClient.process_webhook()`.
596
798
  - Optional: enable **Stripe Issuing** on your test account to provision capped single-use virtual debit cards for each vendor payment.
597
799
 
800
+ ### Local or OpenAI-compatible model (Ollama, LM Studio, vLLM)
801
+
802
+ Briefs can be written by any OpenAI-compatible `/chat/completions` server instead of Nemotron. This is opt-in, uses only the standard library, and is ignored unless you set one of the variables below:
803
+
804
+ ```bash
805
+ ollama pull llama3.2
806
+ export SOLVENT_LLM_MODEL=llama3.2 # base URL defaults to Ollama
807
+ # export SOLVENT_LLM_BASE_URL=http://localhost:11434/v1 # or any OpenAI-compatible URL
808
+ # export SOLVENT_LLM_API_KEY=... # optional; sent only as a Bearer header
809
+ solvent --no-onboard
810
+ ```
811
+
812
+ If the server is down, slow (`SOLVENT_LLM_TIMEOUT`, default 30s), or returns an error or malformed reply, SOLVENT logs a warning and falls back to the deterministic offline brief, so the job still completes. The API key is never logged or written to a brief.
813
+
814
+ **API keys.** `SOLVENT_LLM_API_KEY` stays on your machine: it is read from the environment and sent only as a Bearer header to the configured `SOLVENT_LLM_BASE_URL`, never anywhere else. Redirects are refused rather than followed, so a 3xx can never forward the key to another host or downgrade it to `http://`; point the base URL at the final endpoint. Give each tool its own key with a spend cap, so one leaked or misbehaving key can only spend so much.
815
+
598
816
  ### Environment variables
599
817
 
600
818
  | Variable | Purpose |
@@ -612,6 +830,10 @@ With both keys set:
612
830
  | `SOLVENT_TELEGRAM_ALLOW_FROM` | Comma-separated Telegram user IDs for allowlist mode |
613
831
  | `SOLVENT_PORT` | Port for the `serve` API server (default `8787`) |
614
832
  | `SOLVENT_BASE_URL` | Base URL for hosted brief links and Stripe webhook callbacks |
833
+ | `SOLVENT_LLM_BASE_URL` | Opt-in OpenAI-compatible endpoint for briefs (default when enabled: `http://localhost:11434/v1`, Ollama) |
834
+ | `SOLVENT_LLM_MODEL` | Model name for that endpoint; setting it alone enables the backend (default `llama3.2`) |
835
+ | `SOLVENT_LLM_API_KEY` | Optional Bearer key for the endpoint; never logged |
836
+ | `SOLVENT_LLM_TIMEOUT` | Request timeout in seconds for that endpoint (default `30`) |
615
837
  | `NEMOTRON_MODEL` | Nemotron model override (default: `nvidia/llama-3.1-nemotron-ultra-253b-v1`) |
616
838
  | `SOLVENT_DELIVERY_SECRET` | HMAC token secret for `/briefs/{job_id}`; at least 32 characters, high entropy |
617
839
  | `SOLVENT_SKIP_ONBOARD` | Set to `1` to skip the first-run wizard |
@@ -694,9 +916,13 @@ solvent/
694
916
  products.py the price list (`solvent products`)
695
917
  quality.py deliverable quality gate (`solvent quality`)
696
918
  review.py operator approval queue (`solvent review`)
919
+ funnel.py order funnel and win rate (`solvent funnel`)
920
+ turnaround.py measured delivery time (`solvent turnaround`)
921
+ ops.py operator-only business commands on the chat surface
697
922
  stripe_client.py two-sided Stripe layer (earn + spend)
698
923
  nemotron.py NVIDIA Nemotron client (+ offline stub)
699
924
  service.py the product: an on-demand research brief
925
+ pdf.py dependency-free PDF rendering of the brief
700
926
  jobs.py sample inbound work
701
927
  dashboard.py renders the treasury to HTML + JSON
702
928
  finance.py income statement · unit economics · runway · forecast
@@ -6,6 +6,7 @@
6
6
 
7
7
  [![CI](https://github.com/ianalloway/solvent-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/ianalloway/solvent-agent/actions/workflows/ci.yml)
8
8
  [![PyPI](https://img.shields.io/pypi/v/solvent-agent.svg)](https://pypi.org/project/solvent-agent/)
9
+ [![Hugging Face Space](https://img.shields.io/badge/%F0%9F%A4%97%20Space-try%20in%20browser-yellow)](https://huggingface.co/spaces/ianalloway/solvent-agent-demo)
9
10
  [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue?logo=python&logoColor=white)](https://www.python.org/downloads/)
10
11
  [![Zero dependencies](https://img.shields.io/badge/core%20deps-0-brightgreen)](pyproject.toml)
11
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
@@ -18,6 +19,11 @@
18
19
 
19
20
  <sub>Recorded from the real CLI with no API keys and no network access: the Stripe checkout links and Nemotron output are the built-in offline stubs, and the dollar figures are simulated.</sub>
20
21
 
22
+ **[▶ Try it in your browser](https://huggingface.co/spaces/ianalloway/solvent-agent-demo)**: the real package running in Pyodide, with no install and no keys.
23
+
24
+ [![The SOLVENT Hugging Face Space: five jobs run, J3 declined with a $15 counter-offer, and a simulated P&L snapshot](docs/hf-space-demo.png)](https://huggingface.co/spaces/ianalloway/solvent-agent-demo)
25
+
26
+ <sub>All dollar figures, including the final cash balance, are simulated.</sub>
21
27
  </div>
22
28
 
23
29
  > **Demo by default.** With no keys set, `solvent` runs an **offline, zero-key simulation** of the whole loop. Dollar figures in the demo are illustrative, **not production revenue**. Stripe test-mode Payment Links and live NVIDIA Nemotron are opt-in; see [Make It Real](#-make-it-real).
@@ -155,7 +161,10 @@ A typical **offline demo** batch (illustrative numbers from the simulated run
155
161
  BOOK P&L ──▶ treasury updated · dashboard refreshed
156
162
  ```
157
163
 
158
- Revenue is **always collected before cost is incurred**, and no payment can violate policy. The business is safe by construction and profitable by rule.
164
+ The job pipeline checks payment before fulfilment and screens vendor payments
165
+ against the configured spend policy. Quotes use estimated costs, so an accepted
166
+ quote is a projected margin, not a guarantee of realized profit. The ledger and
167
+ cost-calibration reports show how actual spending compares with that estimate.
159
168
 
160
169
  ---
161
170
 
@@ -177,6 +186,11 @@ Revenue is **always collected before cost is incurred**, and no payment can viol
177
186
  - **Offline-first** — without API keys the demo runs on deterministic stubs. Add `NVIDIA_API_KEY` + `STRIPE_API_KEY=sk_test_...` to unlock live inference and real Payment Links.
178
187
  - **Audit trail** — every `cs_...` checkout session ID and `pi_...` PaymentIntent ID is recorded on the ledger before fulfilment begins.
179
188
 
189
+ Resource prices can be overridden in `.solvent/pricing_overrides.json`. Values
190
+ are costs in cents and must be finite, non-negative numbers; `0` is valid for a
191
+ free resource. Invalid entries (including negative values, booleans, `NaN`, and
192
+ infinity) keep the built-in price. Valid entries in the same file still apply.
193
+
180
194
  ---
181
195
 
182
196
  ## 🎮 Running Modes
@@ -254,6 +268,8 @@ python3 -m solvent export --since 30d # ledger/jobs/metrics/customers + peri
254
268
  python3 -m solvent products # the price list, checked against current cost
255
269
  python3 -m solvent quality # scores the quality gate gave shipped briefs
256
270
  python3 -m solvent review # approve/reject the jobs intake held for a human
271
+ python3 -m solvent funnel --since 7d # win rate: submitted → quoted → paid → delivered
272
+ python3 -m solvent turnaround # measured paid-to-delivered time, and the promise
257
273
  python3 -m solvent reconcile --since 7d # Stripe ↔ ledger drift check
258
274
  python3 -m solvent finance # income statement, unit economics, runway
259
275
  python3 -m solvent finance --json # machine-readable report
@@ -335,6 +351,65 @@ directory, move them into this resolved directory before upgrading.
335
351
  rolling window is used, per-vendor exposure against its cap, and every spend
336
352
  the policy blocked.
337
353
 
354
+ ### Spending controls — per-job envelopes and human approval
355
+
356
+ Two more rules sit on top of the daily, per-vendor and velocity limits. Both
357
+ run inside the same deterministic guardrail check, before any Stripe call, and
358
+ both fail closed.
359
+
360
+ | Rule | Decision rule id | What happens |
361
+ |---|---|---|
362
+ | **Per-job budget envelope** | `job_budget` | Every job has a hard spend cap in integer cents (`job_budget_cents`, default 15000 = $150; `0` turns it off). Spend up to *exactly* the cap is allowed; anything that would go past it is refused with "job '…' budget exhausted". Refunds don't count against it. |
363
+ | **Human-approval hook** | `approval_pending` / `approval_denied` | A spend *strictly above* `approval_threshold_cents` is not paid. It goes to a pluggable approver, which answers `"approved"`, `"denied"` or `"held"`. The default approver always holds. An approver that raises an error or returns anything else also holds. Approval cannot override a hard rule such as the job envelope. |
364
+
365
+ Configure them in `.solvent/spend_policy.json`:
366
+
367
+ ```json
368
+ { "job_budget_cents": 8000, "approval_threshold_cents": 2500 }
369
+ ```
370
+
371
+ or with the environment variables `SOLVENT_JOB_BUDGET_CENTS` and
372
+ `SOLVENT_APPROVAL_THRESHOLD_CENTS`. Env vars can only **tighten** these
373
+ settings: a lower budget or a lower threshold. An env value that would raise
374
+ or disable a limit (for example `SOLVENT_JOB_BUDGET_CENTS=0`) is ignored.
375
+ Loosening or disabling takes an explicit policy-file setting, and any override
376
+ that loosens or disables a limit, or is ignored, is logged as a warning and
377
+ shown in `solvent guardrails`. The approval hook is off unless a threshold is
378
+ set.
379
+
380
+ A spend evaluated without a job id is not exempt from the budget. All unscoped
381
+ spends share one `unscoped` envelope under the same cap.
382
+
383
+ Wire in your own approver (a Slack prompt, a ticket queue, a CLI confirm):
384
+
385
+ ```python
386
+ from solvent.guardrails import ApprovalRequest, Guardrails
387
+
388
+ def approver(req: ApprovalRequest) -> str:
389
+ return "approved" if req.vendor == "market-data-api" else "held"
390
+
391
+ guard = Guardrails(treasury, approver=approver)
392
+ ```
393
+
394
+ A held spend stops the job and emits a `spend_held` event, so nothing moves
395
+ until a human decides.
396
+
397
+ **How this relates to the card networks.** The pattern is close to what the
398
+ networks describe publicly. Mastercard's
399
+ [Agent Pay for Machines](https://www.mastercard.com/global/en/news-and-trends/press/2026/october/Mastercard-Agent-Pay-for-Machines-in-action.html)
400
+ gives an agent "a job and a spending limit", and Mastercard's
401
+ [agentic token framework](https://www.mastercard.com/us/en/news-and-trends/stories/2025/agentic-commerce-framework.html)
402
+ carries transaction limits and validity windows. In the
403
+ [Visa, BILL and Billtrust pilot](https://corporate.visa.com/en/sites/visa-perspectives/newsroom/visa-bill-billtrust-launch-pilot.html),
404
+ the agent only pays invoices the buyer has already approved. Stripe's
405
+ [Issuing for agents](https://docs.stripe.com/issuing/agents) offers per-card and
406
+ per-cardholder
407
+ [spending controls](https://docs.stripe.com/issuing/controls/spending-controls)
408
+ and real-time authorization webhooks. Those controls run at the card or network
409
+ layer. Solvent's run inside the agent, before it calls any payment API, so they
410
+ work in simulation and with any payment backend. They complement network-level
411
+ controls and don't replace them.
412
+
338
413
  ### Backlog — which job to work on next
339
414
 
340
415
  A queue is not a plan. When several jobs are open and both cash and the 24h
@@ -483,6 +558,102 @@ a single JSON document, over a period given as `7d`, `24h`, `2w`, `3m` or
483
558
  revenue and cost counted when the money moved — so the printed summary ties out
484
559
  against the rows it ships, with refunds separated from cost of sales.
485
560
 
561
+ ### How long it takes
562
+
563
+ "When will I get it?" is the second question every customer asks, and the agent
564
+ had no answer. Fulfilment seconds were recorded, but that is only the model's
565
+ part — what the customer experiences is the wait from *paying* to *having the
566
+ brief*, across the queue, the worker's poll, retries and delivery.
567
+
568
+ That wait is already on the record (the ledger says when the money arrived, the
569
+ event log says when the brief went out), so `solvent turnaround` measures it
570
+ rather than estimating, and turns the distribution into something quotable: a
571
+ promise at a chosen percentile, rounded up, never faster than a floor. The
572
+ promise then appears on accepted quotes:
573
+
574
+ ```
575
+ Verdict ACCEPT — accepted
576
+ Turnaround within 5 minutes
577
+ ```
578
+
579
+ It also retires an arbitrary threshold. `alerts.py` called a paid job "stuck"
580
+ after two hours because two hours sounded reasonable; a shop that normally
581
+ delivers in forty seconds should not wait two hours to notice, and one that
582
+ takes a day should not cry wolf. With enough history, late is defined by the
583
+ shop's own p95 — and only until then by the fixed hours.
584
+
585
+ ### Running it from Telegram
586
+
587
+ Fifteen commands' worth of reporting lived in the CLI, which is where the
588
+ operator is not. The gateway's slash commands now cover the business too —
589
+ `/funnel`, `/customers`, `/capacity`, `/alerts`, `/products`, `/quality`,
590
+ `/review`, `/costs` — each a short read over local state, no model call, no
591
+ spend.
592
+
593
+ The catch is who is asking. Any *paired* Telegram user reaches that handler,
594
+ and a customer is not entitled to the agent's win rate or cash position, so
595
+ these are operator-only:
596
+
597
+ - the **dashboard** channel is the operator by construction — it sits behind
598
+ `SOLVENT_DASHBOARD_TOKEN`;
599
+ - a Telegram id in `SOLVENT_OPERATOR_IDS` is the operator;
600
+ - anyone else gets "That command is operator-only", and `/help` does not list
601
+ commands they cannot run.
602
+
603
+ ### The deliverable
604
+
605
+ Every job has been billed `pdf-render-saas` since the first commit, and no PDF
606
+ was ever produced — the agent was paying itself for a deliverable it did not
607
+ deliver. It renders one now: markdown, HTML **and** PDF, written beside each
608
+ other in `data/reports/`, linked from the delivery email and served from the
609
+ same token-gated route as the brief itself (`&format=pdf`), so nothing new is
610
+ exposed. With SMTP configured the PDF is attached to the email.
611
+
612
+ The renderer is `solvent/pdf.py`, about 250 lines of stdlib: a PDF is a text
613
+ container with a byte-offset table, so a single-font document needs no library,
614
+ only exact xref offsets. Base-14 Helvetica means nothing is embedded and a
615
+ brief weighs a couple of kilobytes. A render failure is caught and logged
616
+ rather than raised — losing a brief the customer has paid for would be far
617
+ worse than shipping it without the PDF.
618
+
619
+ ### Knowing the win rate
620
+
621
+ **`solvent funnel`.** The treasury has always known the margin on the jobs it
622
+ took; it never knew the win rate. Those are different businesses — a 90% margin
623
+ on one job in ten is a worse shop than a 60% margin on eight, and nothing here
624
+ could tell them apart.
625
+
626
+ Every stage already leaves a trace, so the funnel is a read over data the agent
627
+ keeps anyway: submitted → quoted → accepted → checkout → paid → delivered. What
628
+ falls out at each step is the point, and the three failures want three
629
+ different fixes:
630
+
631
+ | Exit | What it means |
632
+ |---|---|
633
+ | `screened` | held before pricing — cost nothing |
634
+ | `declined` | a customer who wanted to pay and was turned away by the margin gate |
635
+ | `abandoned` | a customer who said yes at checkout and then did not pay |
636
+ | `refunded` | paid, then refunded when fulfilment failed — the expensive loss |
637
+
638
+ Lost pipeline is reported at the price the customer named, because that is the
639
+ money that was on the table. Win rate is broken down by product and price band,
640
+ and counter-offers are tracked separately, so "does the counter-offer actually
641
+ convert?" finally has an answer.
642
+
643
+ ```
644
+ Submitted 9 ████████████████████████ 100.0%
645
+ Accepted by the gate 6 ████████████████········ 66.7%
646
+ Paid 5 █████████████··········· 55.6%
647
+
648
+ Gate acceptance 85.7% · payment conversion 83.3%
649
+ Won $373.00 · left on the table $9,130.00
650
+ Counter-offers 1 made, 0 won (0.0%)
651
+ ```
652
+
653
+ The same picture now has a **Commercial** panel in the HTML dashboard — win
654
+ rate, customer book, brief quality and the throughput ceiling beside the P&L —
655
+ so the business reads in one place instead of across six commands.
656
+
486
657
  ### Selling, not just quoting
487
658
 
488
659
  **`solvent products` — a price list.** A shop that cannot say what it charges
@@ -497,8 +668,29 @@ curl -X POST localhost:8787/jobs -d '{"topic": "...", "product": "deep-dive", ..
497
668
 
498
669
  The catalogue is priced against *current* costs — calibration included — so a
499
670
  list price that has quietly stopped clearing the margin floor shows up as a
500
- stale price rather than as a run of unprofitable work. The command exits
501
- non-zero when any product has gone stale, so cron can catch it.
671
+ stale price rather than as a run of unprofitable work.
672
+
673
+ Changing what the shop sells is a business decision, not a code change, so the
674
+ built-in list is overridable in `.solvent/products.json` beside the spend and
675
+ intake policies. Entries merge by slug — naming only a price keeps the built-in
676
+ scope — `retire` drops a product, and `replace: true` makes the file the whole
677
+ list:
678
+
679
+ ```json
680
+ {
681
+ "products": [
682
+ { "slug": "express", "list_price_cents": 3500 },
683
+ { "slug": "retainer", "name": "Monthly retainer", "list_price_cents": 49900,
684
+ "est_tokens": 60000, "market_data_calls": 12, "web_search_calls": 40 }
685
+ ],
686
+ "retire": ["deep-dive"]
687
+ }
688
+ ```
689
+
690
+ A malformed entry is dropped rather than sold — a half-read product is a price
691
+ nobody agreed to — and `solvent products --validate` reports it. The command
692
+ exits non-zero when any product has gone stale *or* the file is broken, so cron
693
+ catches either without reading the output.
502
694
 
503
695
  **`solvent quality` — a gate on the product, not just the margin.** Every other
504
696
  gate here asks whether the money is sound; this one asks whether the brief is.
@@ -554,6 +746,22 @@ With both keys set:
554
746
  - Optional: set `STRIPE_WEBHOOK_SECRET` and forward `checkout.session.completed` events via `StripeClient.process_webhook()`.
555
747
  - Optional: enable **Stripe Issuing** on your test account to provision capped single-use virtual debit cards for each vendor payment.
556
748
 
749
+ ### Local or OpenAI-compatible model (Ollama, LM Studio, vLLM)
750
+
751
+ Briefs can be written by any OpenAI-compatible `/chat/completions` server instead of Nemotron. This is opt-in, uses only the standard library, and is ignored unless you set one of the variables below:
752
+
753
+ ```bash
754
+ ollama pull llama3.2
755
+ export SOLVENT_LLM_MODEL=llama3.2 # base URL defaults to Ollama
756
+ # export SOLVENT_LLM_BASE_URL=http://localhost:11434/v1 # or any OpenAI-compatible URL
757
+ # export SOLVENT_LLM_API_KEY=... # optional; sent only as a Bearer header
758
+ solvent --no-onboard
759
+ ```
760
+
761
+ If the server is down, slow (`SOLVENT_LLM_TIMEOUT`, default 30s), or returns an error or malformed reply, SOLVENT logs a warning and falls back to the deterministic offline brief, so the job still completes. The API key is never logged or written to a brief.
762
+
763
+ **API keys.** `SOLVENT_LLM_API_KEY` stays on your machine: it is read from the environment and sent only as a Bearer header to the configured `SOLVENT_LLM_BASE_URL`, never anywhere else. Redirects are refused rather than followed, so a 3xx can never forward the key to another host or downgrade it to `http://`; point the base URL at the final endpoint. Give each tool its own key with a spend cap, so one leaked or misbehaving key can only spend so much.
764
+
557
765
  ### Environment variables
558
766
 
559
767
  | Variable | Purpose |
@@ -571,6 +779,10 @@ With both keys set:
571
779
  | `SOLVENT_TELEGRAM_ALLOW_FROM` | Comma-separated Telegram user IDs for allowlist mode |
572
780
  | `SOLVENT_PORT` | Port for the `serve` API server (default `8787`) |
573
781
  | `SOLVENT_BASE_URL` | Base URL for hosted brief links and Stripe webhook callbacks |
782
+ | `SOLVENT_LLM_BASE_URL` | Opt-in OpenAI-compatible endpoint for briefs (default when enabled: `http://localhost:11434/v1`, Ollama) |
783
+ | `SOLVENT_LLM_MODEL` | Model name for that endpoint; setting it alone enables the backend (default `llama3.2`) |
784
+ | `SOLVENT_LLM_API_KEY` | Optional Bearer key for the endpoint; never logged |
785
+ | `SOLVENT_LLM_TIMEOUT` | Request timeout in seconds for that endpoint (default `30`) |
574
786
  | `NEMOTRON_MODEL` | Nemotron model override (default: `nvidia/llama-3.1-nemotron-ultra-253b-v1`) |
575
787
  | `SOLVENT_DELIVERY_SECRET` | HMAC token secret for `/briefs/{job_id}`; at least 32 characters, high entropy |
576
788
  | `SOLVENT_SKIP_ONBOARD` | Set to `1` to skip the first-run wizard |
@@ -653,9 +865,13 @@ solvent/
653
865
  products.py the price list (`solvent products`)
654
866
  quality.py deliverable quality gate (`solvent quality`)
655
867
  review.py operator approval queue (`solvent review`)
868
+ funnel.py order funnel and win rate (`solvent funnel`)
869
+ turnaround.py measured delivery time (`solvent turnaround`)
870
+ ops.py operator-only business commands on the chat surface
656
871
  stripe_client.py two-sided Stripe layer (earn + spend)
657
872
  nemotron.py NVIDIA Nemotron client (+ offline stub)
658
873
  service.py the product: an on-demand research brief
874
+ pdf.py dependency-free PDF rendering of the brief
659
875
  jobs.py sample inbound work
660
876
  dashboard.py renders the treasury to HTML + JSON
661
877
  finance.py income statement · unit economics · runway · forecast
@@ -24,10 +24,10 @@ classifiers = [
24
24
  dependencies = []
25
25
 
26
26
  [project.optional-dependencies]
27
- stripe = ["stripe>=9.0.0"]
28
- serve = ["fastapi>=0.110.0", "uvicorn[standard]>=0.27.0"]
29
- qr = ["qrcode[pil]>=7.0"]
30
- telegram = ["python-telegram-bot>=21.0"]
27
+ stripe = ["stripe>=9.0.0", "urllib3>=2.8.0", "certifi>=2026.10.19"]
28
+ serve = ["fastapi>=0.110.0", "uvicorn[standard]>=0.27.0", "anyio>=4.14.2"]
29
+ qr = ["qrcode[pil]>=7.0", "pillow>=13.0.0; python_version >= '3.11'"]
30
+ telegram = ["python-telegram-bot>=21.0", "anyio>=4.14.2", "certifi>=2026.10.19"]
31
31
  dev = ["pytest>=7.0.0", "ruff>=0.3"]
32
32
  all = [
33
33
  "stripe>=9.0.0",
@@ -35,6 +35,11 @@ all = [
35
35
  "uvicorn[standard]>=0.27.0",
36
36
  "qrcode[pil]>=7.0",
37
37
  "python-telegram-bot>=21.0",
38
+ # Security floors for transitive deps (verified GHSAs, Oct 2026). See constraints.txt.
39
+ "urllib3>=2.8.0",
40
+ "certifi>=2026.10.19",
41
+ "anyio>=4.14.2",
42
+ "pillow>=13.0.0; python_version >= '3.11'",
38
43
  ]
39
44
 
40
45
  [project.scripts]