solvent-agent 0.2.1__tar.gz → 0.2.2__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 (151) hide show
  1. {solvent_agent-0.2.1/solvent_agent.egg-info → solvent_agent-0.2.2}/PKG-INFO +1 -1
  2. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/__init__.py +1 -1
  3. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/agent.py +15 -4
  4. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/dashboard.py +55 -40
  5. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/export.py +44 -2
  6. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/gateway.py +18 -8
  7. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/stages.py +49 -28
  8. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/treasury.py +28 -13
  9. {solvent_agent-0.2.1 → solvent_agent-0.2.2/solvent_agent.egg-info}/PKG-INFO +1 -1
  10. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_dashboard_ops_xss.py +48 -0
  11. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_export.py +48 -0
  12. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_gateway.py +30 -0
  13. solvent_agent-0.2.2/tests/test_server_job_submission.py +152 -0
  14. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_stages.py +14 -0
  15. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_treasury.py +9 -0
  16. solvent_agent-0.2.1/tests/test_server_job_submission.py +0 -76
  17. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/LICENSE +0 -0
  18. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/README.md +0 -0
  19. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/pyproject.toml +0 -0
  20. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/setup.cfg +0 -0
  21. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/__main__.py +0 -0
  22. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/_filelock.py +0 -0
  23. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/access.py +0 -0
  24. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/alerts.py +0 -0
  25. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/backlog.py +0 -0
  26. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/calibration.py +0 -0
  27. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/capacity.py +0 -0
  28. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/channels/__init__.py +0 -0
  29. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/channels/telegram.py +0 -0
  30. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/chat.py +0 -0
  31. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/checkout.py +0 -0
  32. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/cli.py +0 -0
  33. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/config.py +0 -0
  34. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/config_cmd.py +0 -0
  35. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/customers.py +0 -0
  36. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/dashboard_chat.py +0 -0
  37. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/delivery.py +0 -0
  38. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/doctor.py +0 -0
  39. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/event_hub.py +0 -0
  40. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/finance.py +0 -0
  41. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/guardrail_cmd.py +0 -0
  42. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/guardrails.py +0 -0
  43. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/init.py +0 -0
  44. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/intake.py +0 -0
  45. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/job_cmd.py +0 -0
  46. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/jobs.py +0 -0
  47. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/logs.py +0 -0
  48. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/memory.py +0 -0
  49. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/money.py +0 -0
  50. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/nemotron.py +0 -0
  51. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/notifications.py +0 -0
  52. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/observability.py +0 -0
  53. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/onboarding.py +0 -0
  54. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/optimize.py +0 -0
  55. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/pairing.py +0 -0
  56. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/paths.py +0 -0
  57. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/pricing.py +0 -0
  58. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/products.py +0 -0
  59. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/py.typed +0 -0
  60. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/qr.py +0 -0
  61. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/quality.py +0 -0
  62. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/queue.py +0 -0
  63. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/quote_cmd.py +0 -0
  64. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/rate_limit.py +0 -0
  65. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/receipt.py +0 -0
  66. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/reconcile.py +0 -0
  67. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/review.py +0 -0
  68. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/security.py +0 -0
  69. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/server.py +0 -0
  70. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/service.py +0 -0
  71. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/simulate.py +0 -0
  72. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/status.py +0 -0
  73. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/stripe_client.py +0 -0
  74. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/templates/workspace/AGENTS.md +0 -0
  75. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/templates/workspace/BRAIN.md +0 -0
  76. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/templates/workspace/SOUL.md +0 -0
  77. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/templates/workspace/skills/commission-research/SKILL.md +0 -0
  78. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/tools.py +0 -0
  79. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/upgrade.py +0 -0
  80. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/webhook_log.py +0 -0
  81. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/worker.py +0 -0
  82. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent/workspace.py +0 -0
  83. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/SOURCES.txt +0 -0
  84. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/dependency_links.txt +0 -0
  85. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/entry_points.txt +0 -0
  86. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/requires.txt +0 -0
  87. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/solvent_agent.egg-info/top_level.txt +0 -0
  88. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_agent.py +0 -0
  89. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_alerts.py +0 -0
  90. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_backlog.py +0 -0
  91. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_calibration.py +0 -0
  92. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_capacity.py +0 -0
  93. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_chat_tools.py +0 -0
  94. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_checkout.py +0 -0
  95. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_cli_routing.py +0 -0
  96. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_cogs_reconciliation.py +0 -0
  97. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_config.py +0 -0
  98. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_config_cmd.py +0 -0
  99. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_counter_offer.py +0 -0
  100. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_customers.py +0 -0
  101. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_dashboard_chat.py +0 -0
  102. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_dashboard_financials.py +0 -0
  103. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_dashboard_security.py +0 -0
  104. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_delivery.py +0 -0
  105. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_doctor.py +0 -0
  106. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_event_hub.py +0 -0
  107. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_filelock.py +0 -0
  108. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_finance.py +0 -0
  109. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_guardrails.py +0 -0
  110. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_init.py +0 -0
  111. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_intake.py +0 -0
  112. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_job_cmd.py +0 -0
  113. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_jobs.py +0 -0
  114. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_logs.py +0 -0
  115. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_memory.py +0 -0
  116. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_metrics_block.py +0 -0
  117. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_money.py +0 -0
  118. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_nemotron_parse.py +0 -0
  119. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_notifications.py +0 -0
  120. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_observability.py +0 -0
  121. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_onboarding.py +0 -0
  122. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_optimize.py +0 -0
  123. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_packaging.py +0 -0
  124. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_paths.py +0 -0
  125. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_pricing.py +0 -0
  126. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_products.py +0 -0
  127. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_qr.py +0 -0
  128. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_quality.py +0 -0
  129. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_queue.py +0 -0
  130. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_rate_limit.py +0 -0
  131. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_receipt.py +0 -0
  132. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_reconcile.py +0 -0
  133. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_research_brief.py +0 -0
  134. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_retry.py +0 -0
  135. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_review.py +0 -0
  136. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_route_security.py +0 -0
  137. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_runtime_config_paths.py +0 -0
  138. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_security.py +0 -0
  139. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_server.py +0 -0
  140. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_simulate.py +0 -0
  141. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_spend_policy.py +0 -0
  142. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_status.py +0 -0
  143. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_stripe_client.py +0 -0
  144. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_telegram_pairing.py +0 -0
  145. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_tools.py +0 -0
  146. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_upgrade.py +0 -0
  147. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_upgrade_hint.py +0 -0
  148. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_webhook_log.py +0 -0
  149. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_webhook_security.py +0 -0
  150. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/tests/test_worker.py +0 -0
  151. {solvent_agent-0.2.1 → solvent_agent-0.2.2}/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.1
3
+ Version: 0.2.2
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
@@ -1,3 +1,3 @@
1
1
  """SOLVENT — a self-funding analyst agent. See run_demo.py."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
@@ -22,7 +22,7 @@ from collections.abc import Callable
22
22
  from .calibration import calibration_factor
23
23
  from .guardrails import Guardrails
24
24
  from .pricing import PricingPolicy
25
- from .stages import StageRunner, _job_id_of, validate_and_coerce_job
25
+ from .stages import StageRunner, _job_id_of, coerce_job, screen_and_record
26
26
  from .stripe_client import StripeClient
27
27
  from .treasury import Treasury
28
28
 
@@ -79,10 +79,21 @@ class Solvent:
79
79
  return self._runner.advance_job(job_id)
80
80
 
81
81
  def enqueue_job(self, job: dict) -> dict:
82
- """Validate and persist a job for async worker processing."""
83
- validated, err = validate_and_coerce_job(job, self.t)
82
+ """Validate and persist a new job for async worker processing.
83
+
84
+ Invalid input is declined before anything is written. A valid job then
85
+ claims its id with an atomic insert, so a submission can never
86
+ overwrite or restart an existing job.
87
+ """
88
+ coerced, err = coerce_job(job)
89
+ if err:
90
+ return self._emit(stage="declined", job_id=_job_id_of(job, None), reason=err)
91
+ assert coerced is not None
92
+ if not self.t.insert_job(coerced["id"], "received"):
93
+ return {"stage": "declined", "job_id": coerced["id"], "reason": "job id already exists"}
94
+ validated, err = screen_and_record(coerced, self.t)
84
95
  if err:
85
- return self._emit(stage="declined", job_id=_job_id_of(job, validated), reason=err)
96
+ return self._emit(stage="declined", job_id=coerced["id"], reason=err)
86
97
  assert validated is not None
87
98
  q = self._runner._stage_quote(validated)
88
99
  if q.get("stage") == "declined" or not q.get("accept"):
@@ -38,6 +38,8 @@ def _script_json(value: object) -> str:
38
38
  .replace("<", "\\u003c")
39
39
  .replace(">", "\\u003e")
40
40
  .replace("&", "\\u0026")
41
+ .replace("\u2028", "\\u2028")
42
+ .replace("\u2029", "\\u2029")
41
43
  )
42
44
 
43
45
 
@@ -1367,15 +1369,6 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
1367
1369
  let jobsData = {_script_json(jobs_data)};
1368
1370
  let currentOpenJobId = null;
1369
1371
 
1370
- function esc(value) {{
1371
- return String(value ?? "")
1372
- .replace(/&/g, "&amp;")
1373
- .replace(/</g, "&lt;")
1374
- .replace(/>/g, "&gt;")
1375
- .replace(/"/g, "&quot;")
1376
- .replace(/'/g, "&#39;");
1377
- }}
1378
-
1379
1372
  function openBriefModal(jobId) {{
1380
1373
  document.getElementById("modal-title").innerText = "Research Brief Preview: " + jobId;
1381
1374
  const iframe = document.getElementById("brief-iframe");
@@ -1398,21 +1391,28 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
1398
1391
  return;
1399
1392
  }}
1400
1393
 
1401
- let html = "";
1394
+ container.replaceChildren();
1402
1395
  jobIds.forEach(jobId => {{
1403
1396
  const job = jobsData[jobId] || {{}};
1404
- const title = job.title || `Research Brief (${{jobId}})`;
1405
- html += `
1406
- <div style="display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.02); border:1px solid var(--color-border); padding:10px 14px; border-radius:10px; margin-bottom: 8px;">
1407
- <div style="display:flex; flex-direction:column; gap:2px; max-width:70%;">
1408
- <span style="font-size:11px; font-family:var(--font-mono); color:var(--color-text-muted);">${{esc(jobId)}}</span>
1409
- <span style="font-size:13px; font-weight:600; color:var(--color-text-primary); text-overflow:ellipsis; overflow:hidden; white-space:nowrap;">${{esc(title)}}</span>
1410
- </div>
1411
- <button class="btn btn-outline btn-sm" onclick="openBriefModal(${{esc(JSON.stringify(jobId))}})" style="padding:4px 8px; font-size:11px;">Preview</button>
1412
- </div>
1413
- `;
1397
+ const row = document.createElement("div");
1398
+ row.style.cssText = "display:flex; justify-content:space-between; align-items:center; background:rgba(255,255,255,0.02); border:1px solid var(--color-border); padding:10px 14px; border-radius:10px; margin-bottom: 8px;";
1399
+ const labels = document.createElement("div");
1400
+ labels.style.cssText = "display:flex; flex-direction:column; gap:2px; max-width:70%;";
1401
+ const idEl = document.createElement("span");
1402
+ idEl.style.cssText = "font-size:11px; font-family:var(--font-mono); color:var(--color-text-muted);";
1403
+ idEl.textContent = jobId;
1404
+ const titleEl = document.createElement("span");
1405
+ titleEl.style.cssText = "font-size:13px; font-weight:600; color:var(--color-text-primary); text-overflow:ellipsis; overflow:hidden; white-space:nowrap;";
1406
+ titleEl.textContent = job.title || `Research Brief (${{jobId}})`;
1407
+ labels.append(idEl, titleEl);
1408
+ const button = document.createElement("button");
1409
+ button.className = "btn btn-outline btn-sm";
1410
+ button.style.cssText = "padding:4px 8px; font-size:11px;";
1411
+ button.textContent = "Preview";
1412
+ button.addEventListener("click", () => openBriefModal(jobId));
1413
+ row.append(labels, button);
1414
+ container.appendChild(row);
1414
1415
  }});
1415
- container.innerHTML = html;
1416
1416
  }}
1417
1417
 
1418
1418
  // Auto-scroll terminal log to the bottom
@@ -1431,28 +1431,43 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
1431
1431
 
1432
1432
  document.getElementById("drawer-title").innerText = "Job Details: " + jobId;
1433
1433
 
1434
- let metaHTML = `
1435
- <div class="meta-row"><strong>Title:</strong> <span>${{esc(job.title || 'N/A')}}</span></div>
1436
- <div class="meta-row"><strong>Status:</strong> <span class="badge badge-${{esc(job.status)}}">${{esc(String(job.status).toUpperCase())}}</span></div>
1437
- <div class="meta-row"><strong>Client Budget:</strong> <span>${{formatCents(job.price)}}</span></div>
1438
- <div class="meta-row"><strong>Incurred COGS:</strong> <span>${{formatCents(getExpensesTotal(job.expenses))}}</span></div>
1439
- <div class="meta-row"><strong>Net P&L:</strong> <span class="${{job.pnl >= 0 ? 'green' : 'red'}}">${{job.pnl >= 0 ? '+' : ''}}${{formatCents(job.pnl)}}</span></div>
1440
- <div class="meta-row"><strong>Est. Margin:</strong> <span>${{esc(job.margin_pct)}}%</span></div>
1441
- `;
1434
+ const meta = document.getElementById("drawer-metadata");
1435
+ meta.replaceChildren();
1436
+ const metaRow = (label, value, className) => {{
1437
+ const row = document.createElement("div");
1438
+ row.className = "meta-row";
1439
+ const strong = document.createElement("strong");
1440
+ strong.textContent = label;
1441
+ const span = document.createElement("span");
1442
+ if (className) span.className = className;
1443
+ span.textContent = value;
1444
+ row.append(strong, " ", span);
1445
+ meta.appendChild(row);
1446
+ }};
1447
+ const status = String(job.status);
1448
+ metaRow("Title:", job.title || "N/A");
1449
+ metaRow("Status:", status.toUpperCase(), "badge badge-" + status.replace(/[^a-z_]/gi, ""));
1450
+ metaRow("Client Budget:", formatCents(job.price));
1451
+ metaRow("Incurred COGS:", formatCents(getExpensesTotal(job.expenses)));
1452
+ metaRow("Net P&L:", (job.pnl >= 0 ? "+" : "") + formatCents(job.pnl), job.pnl >= 0 ? "green" : "red");
1453
+ metaRow("Est. Margin:", String(job.margin_pct) + "%");
1442
1454
 
1443
1455
  if (job.expenses.length > 0) {{
1444
- metaHTML += `
1445
- <h4 style="margin-top: 16px; margin-bottom: 8px; font-size:12px; color:var(--color-text-muted)">ITEMIZED EXPENSES</h4>
1446
- <ul class="drawer-expenses-list">
1447
- `;
1456
+ const heading = document.createElement("h4");
1457
+ heading.style.cssText = "margin-top: 16px; margin-bottom: 8px; font-size:12px; color:var(--color-text-muted)";
1458
+ heading.textContent = "ITEMIZED EXPENSES";
1459
+ const list = document.createElement("ul");
1460
+ list.className = "drawer-expenses-list";
1448
1461
  job.expenses.forEach(e => {{
1449
- metaHTML += `<li><strong>${{esc(e.vendor)}}:</strong> -${{formatCents(e.amount)}} (${{esc(e.memo)}})</li>`;
1462
+ const item = document.createElement("li");
1463
+ const vendor = document.createElement("strong");
1464
+ vendor.textContent = String(e.vendor) + ":";
1465
+ item.append(vendor, ` -${{formatCents(e.amount)}} (${{e.memo}})`);
1466
+ list.appendChild(item);
1450
1467
  }});
1451
- metaHTML += '</ul>';
1468
+ meta.append(heading, list);
1452
1469
  }}
1453
1470
 
1454
- document.getElementById("drawer-metadata").innerHTML = metaHTML;
1455
-
1456
1471
  const hasBrief = Object.prototype.hasOwnProperty.call(briefs || {{}}, jobId);
1457
1472
  const reportText = hasBrief
1458
1473
  ? "_Brief generated. Use the hosted brief link with a delivery token to view full contents._"
@@ -1503,13 +1518,13 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
1503
1518
  html = html.replace(/\\*(.*?)\\*/g, '<em>$1</em>');
1504
1519
 
1505
1520
  // Double line breaks to paragraphs
1506
- const paragraphs = html.split(/\n\n+/);
1521
+ const paragraphs = html.split(/\\n\\n+/);
1507
1522
  html = paragraphs.map(p => {{
1508
1523
  p = p.trim();
1509
1524
  if (!p) return '';
1510
1525
  if (p.startsWith('<h') || p.startsWith('<ul') || p.startsWith('<li')) return p;
1511
- return `<p>${{p.replace(/\n/g, '<br>')}}</p>`;
1512
- }}).join('\n');
1526
+ return `<p>${{p.replace(/\\n/g, '<br>')}}</p>`;
1527
+ }}).join("\\n");
1513
1528
 
1514
1529
  return html;
1515
1530
  }}
@@ -228,8 +228,44 @@ _TABLES = {
228
228
  }
229
229
 
230
230
 
231
+ #: Free-text columns that can carry customer- or vendor-supplied strings. Only
232
+ #: these are neutralised, so numeric columns stay numeric for the spreadsheet.
233
+ _UNTRUSTED_TEXT_COLUMNS = {
234
+ "ledger": frozenset(
235
+ {"memo", "job_id", "vendor", "stripe_ref", "stripe_session_id", "stripe_link_id"}
236
+ ),
237
+ "jobs": frozenset(
238
+ {"id", "status", "topic", "customer_email", "current_stage", "error_reason", "deliverable_url"}
239
+ ),
240
+ "metrics": frozenset({"job_id", "decline_reason", "block_rule"}),
241
+ "customers": frozenset({"email"}),
242
+ }
243
+
244
+ _FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
245
+ _LEADING_NOISE = re.compile(r"^[\s\x00-\x1f\x7f\u200b-\u200f\u2060\ufeff]*")
246
+
247
+
248
+ def neutralise_cell(value: Any) -> Any:
249
+ """Stop a spreadsheet from evaluating a text cell as a formula.
250
+
251
+ A string whose first significant character (after any leading whitespace
252
+ or control characters) is ``= + - @``, or that starts with a tab or
253
+ carriage return, is prefixed with a single quote. Non-strings pass through.
254
+ """
255
+ if not isinstance(value, str) or not value:
256
+ return value
257
+ significant = _LEADING_NOISE.sub("", value, count=1)
258
+ if value.startswith(_FORMULA_TRIGGERS) or significant.startswith(_FORMULA_TRIGGERS):
259
+ return "'" + value
260
+ return value
261
+
262
+
231
263
  def write_csv(data: dict[str, Any], out_dir: Path) -> list[Path]:
232
- """Write one CSV per table; returns the files written."""
264
+ """Write one CSV per table; returns the files written.
265
+
266
+ Untrusted text columns are passed through `neutralise_cell` so a job topic
267
+ or memo like ``=HYPERLINK(...)`` is shown as text, not run as a formula.
268
+ """
233
269
  out_dir.mkdir(parents=True, exist_ok=True)
234
270
  written = []
235
271
  for table, columns in _TABLES.items():
@@ -237,8 +273,14 @@ def write_csv(data: dict[str, Any], out_dir: Path) -> list[Path]:
237
273
  with path.open("w", encoding="utf-8", newline="") as fh:
238
274
  writer = csv.DictWriter(fh, fieldnames=list(columns), extrasaction="ignore")
239
275
  writer.writeheader()
276
+ text_columns = _UNTRUSTED_TEXT_COLUMNS.get(table, frozenset())
240
277
  for row in data[table]:
241
- writer.writerow(row)
278
+ writer.writerow(
279
+ {
280
+ key: neutralise_cell(value) if key in text_columns else value
281
+ for key, value in row.items()
282
+ }
283
+ )
242
284
  written.append(path)
243
285
  return written
244
286
 
@@ -38,6 +38,11 @@ def notify(channel: str, external_id: str, text: str) -> None:
38
38
  pass
39
39
 
40
40
 
41
+ #: Commands that answer from local state without calling the model; only these
42
+ #: skip the per-user rate limit. Everything else, /quote included, is counted.
43
+ RATE_EXEMPT_COMMANDS = frozenset({"/help", "/status", "/jobs"})
44
+
45
+
41
46
  def _rate_ok(user_key: str) -> bool:
42
47
  allowed, _reason = _rate_limiter.check(user_key)
43
48
  return allowed
@@ -56,10 +61,16 @@ class Gateway:
56
61
  *,
57
62
  user_label: str | None = None,
58
63
  ) -> str:
64
+ user_key = f"{channel}:{external_id}"
65
+ if _rate_limiter.is_banned(user_key):
66
+ return "You are temporarily blocked from messaging this bot."
67
+
59
68
  session = self.memory.get_or_create(channel, external_id, user_label)
60
- is_command = text.strip().startswith("/")
69
+ stripped = text.strip()
70
+ is_command = stripped.startswith("/")
71
+ command = stripped.split(maxsplit=1)[0].lower() if is_command else ""
61
72
 
62
- if not is_command and not _rate_ok(f"{channel}:{external_id}"):
73
+ if command not in RATE_EXEMPT_COMMANDS and not _rate_ok(user_key):
63
74
  return "Rate limit exceeded. Please wait before sending more messages."
64
75
 
65
76
  if channel == "telegram" and not pairing.is_allowed(external_id, user_label):
@@ -83,7 +94,7 @@ class Gateway:
83
94
  cmd = parts[0].lower()
84
95
  arg = parts[1] if len(parts) > 1 else ""
85
96
 
86
- if cmd == "/help":
97
+ if cmd in ("/help", "/start"):
87
98
  return (
88
99
  "SOLVENT research business bot.\n"
89
100
  "/status — treasury snapshot\n"
@@ -130,13 +141,14 @@ class Gateway:
130
141
  port = 443
131
142
  return _qr.render_token(token, host=host, port=port)
132
143
 
133
- if cmd == "/quote" and "|" in arg:
144
+ if cmd == "/quote":
145
+ if "|" not in arg:
146
+ return "Usage: /quote topic | 50.00"
134
147
  topic, budget_s = [x.strip() for x in arg.split("|", 1)]
135
148
  try:
136
149
  cents = dollars_to_cents(budget_s)
137
150
  except ValueError:
138
151
  return "Usage: /quote topic | 50.00"
139
- from .chat import handle_message
140
152
 
141
153
  prompt = f"Quote a brief on '{topic}' with budget_cents={cents}. Use quote_brief tool."
142
154
  return handle_message(
@@ -147,9 +159,7 @@ class Gateway:
147
159
  channel=channel,
148
160
  )
149
161
 
150
- return handle_message(
151
- session["id"], text, agent=self.agent, memory=self.memory, channel=channel
152
- )
162
+ return f"Unknown command {cmd[:32]}. Send /help for the list of commands."
153
163
 
154
164
  def on_job_event(self, event: dict) -> None:
155
165
  """Push job lifecycle updates to sessions watching a job."""
@@ -29,51 +29,50 @@ def _job_id_of(submitted: Any, validated: dict | None) -> str:
29
29
  for candidate in (validated, submitted):
30
30
  if isinstance(candidate, dict):
31
31
  job_id = candidate.get("id")
32
- if isinstance(job_id, str) and job_id.strip():
32
+ if isinstance(job_id, str) and delivery.is_safe_job_id(job_id):
33
33
  return job_id
34
34
  return "unknown"
35
35
 
36
36
 
37
- def validate_and_coerce_job(job: dict, treasury: Treasury) -> tuple[dict | None, str | None]:
38
- """Return (coerced_job, error_reason)."""
37
+ def _coerce_job(job: Any) -> tuple[dict | None, str | None, str | None]:
38
+ """Field validation with no side effects.
39
+
40
+ Returns ``(coerced_job, error, recorded_reason)``; ``recorded_reason`` is
41
+ what the worker path stores on the job row for that error, or ``None`` when
42
+ the error has never been recorded.
43
+ """
39
44
  if not isinstance(job, dict):
40
- return None, "job must be a dictionary"
45
+ return None, "job must be a dictionary", None
41
46
  job_id = job.get("id")
42
- if not job_id or not isinstance(job_id, str):
43
- return None, "missing or invalid job ID"
47
+ if not job_id or not isinstance(job_id, str) or not delivery.is_safe_job_id(job_id):
48
+ return None, "missing or invalid job ID", None
44
49
  try:
45
50
  job = dict(job)
46
51
  sanitise_job(job)
47
52
  except SOLVENTSecurityError as exc:
48
- treasury.upsert_job(job_id, "failed", error_reason=str(exc))
49
- return None, f"security violation: {exc}"
53
+ return None, f"security violation: {exc}", str(exc)
50
54
 
51
55
  # A job may name a product instead of pricing itself; the product fills in
52
56
  # the scope and list price, and everything below validates it as normal.
53
57
  try:
54
58
  job = apply_product(job)
55
59
  except UnknownProduct as exc:
56
- treasury.upsert_job(job_id, "failed", error_reason=str(exc))
57
- return None, str(exc)
60
+ return None, str(exc), str(exc)
58
61
 
59
62
  topic = job.get("topic")
60
63
  if not topic or not isinstance(topic, str) or not topic.strip():
61
- treasury.upsert_job(job_id, "failed", error_reason="missing or blank topic")
62
- return None, "missing or blank topic"
64
+ return None, "missing or blank topic", "missing or blank topic"
63
65
 
64
66
  budget_cents = job.get("budget_cents")
65
67
  if budget_cents is None:
66
- treasury.upsert_job(job_id, "failed", error_reason="missing budget_cents")
67
- return None, "missing budget_cents"
68
+ return None, "missing budget_cents", "missing budget_cents"
68
69
  try:
69
70
  budget_cents = int(float(budget_cents))
70
- if budget_cents < 0 or budget_cents > 1_000_000:
71
- treasury.upsert_job(job_id, "failed", error_reason="invalid budget")
72
- return None, "invalid budget"
73
- job["budget_cents"] = budget_cents
74
71
  except (ValueError, TypeError):
75
- treasury.upsert_job(job_id, "failed", error_reason="budget_cents must be numeric")
76
- return None, "budget_cents must be a valid numeric value"
72
+ return None, "budget_cents must be a valid numeric value", "budget_cents must be numeric"
73
+ if budget_cents < 0 or budget_cents > 1_000_000:
74
+ return None, "invalid budget", "invalid budget"
75
+ job["budget_cents"] = budget_cents
77
76
 
78
77
  for param, default_val in [
79
78
  ("est_tokens", 8_000),
@@ -85,18 +84,29 @@ def validate_and_coerce_job(job: dict, treasury: Treasury) -> tuple[dict | None,
85
84
  val = default_val
86
85
  try:
87
86
  val = int(float(val))
88
- if val < 0:
89
- return None, f"{param} cannot be negative"
90
- if param == "est_tokens" and val > 100_000:
91
- return None, "est_tokens exceeds maximum limit"
92
- if param in ("market_data_calls", "web_search_calls") and val > 50:
93
- return None, f"{param} exceeds maximum limit"
94
- job[param] = val
95
87
  except (ValueError, TypeError):
96
- return None, f"{param} must be a valid numeric value"
88
+ return None, f"{param} must be a valid numeric value", None
89
+ if val < 0:
90
+ return None, f"{param} cannot be negative", None
91
+ if param == "est_tokens" and val > 100_000:
92
+ return None, "est_tokens exceeds maximum limit", None
93
+ if param in ("market_data_calls", "web_search_calls") and val > 50:
94
+ return None, f"{param} exceeds maximum limit", None
95
+ job[param] = val
97
96
 
98
97
  job.setdefault("customer_email", "client@example.com")
98
+ return job, None, None
99
+
100
+
101
+ def coerce_job(job: Any) -> tuple[dict | None, str | None]:
102
+ """Validate and normalise a submitted job without writing anything."""
103
+ coerced, err, _recorded = _coerce_job(job)
104
+ return coerced, err
99
105
 
106
+
107
+ def screen_and_record(job: dict, treasury: Treasury) -> tuple[dict | None, str | None]:
108
+ """Run the intake screen on a coerced job and record the outcome on its row."""
109
+ job_id = job["id"]
100
110
  # Commercial screen: duplicates, bursts, oversized orders, unreachable
101
111
  # customers. Runs before pricing, so a screened-out job costs nothing.
102
112
  screen = screen_job(job, treasury)
@@ -128,6 +138,17 @@ def validate_and_coerce_job(job: dict, treasury: Treasury) -> tuple[dict | None,
128
138
  return job, None
129
139
 
130
140
 
141
+ def validate_and_coerce_job(job: dict, treasury: Treasury) -> tuple[dict | None, str | None]:
142
+ """Return (coerced_job, error_reason), recording failures on the job row."""
143
+ coerced, err, recorded = _coerce_job(job)
144
+ if err:
145
+ if recorded is not None:
146
+ treasury.upsert_job(job["id"], "failed", error_reason=recorded)
147
+ return None, err
148
+ assert coerced is not None
149
+ return screen_and_record(coerced, treasury)
150
+
151
+
131
152
  class StageRunner:
132
153
  """Runs idempotent stages for a single job."""
133
154
 
@@ -486,19 +486,34 @@ class Treasury:
486
486
  params.append(job_id)
487
487
  conn.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE id = ?", params)
488
488
  else:
489
- cols = ["id", "status", "created_at", "updated_at"]
490
- vals = [job_id, status, ts, ts]
491
- for col in self._JOB_COLS:
492
- cols.append(col)
493
- val = kwargs.get(col)
494
- if col == "job_payload_json" and isinstance(val, dict):
495
- val = json.dumps(val)
496
- vals.append(val)
497
- placeholders = ", ".join(["?"] * len(cols))
498
- conn.execute(
499
- f"INSERT INTO jobs ({', '.join(cols)}) VALUES ({placeholders})",
500
- vals,
501
- )
489
+ self._insert_job_row(conn, job_id, status, ts, kwargs, verb="INSERT")
490
+
491
+ def _insert_job_row(
492
+ self, conn: sqlite3.Connection, job_id: str, status: str, ts: float, kwargs: dict, *, verb: str
493
+ ) -> int:
494
+ cols = ["id", "status", "created_at", "updated_at"]
495
+ vals = [job_id, status, ts, ts]
496
+ for col in self._JOB_COLS:
497
+ cols.append(col)
498
+ val = kwargs.get(col)
499
+ if col == "job_payload_json" and isinstance(val, dict):
500
+ val = json.dumps(val)
501
+ vals.append(val)
502
+ placeholders = ", ".join(["?"] * len(cols))
503
+ cur = conn.execute(f"{verb} INTO jobs ({', '.join(cols)}) VALUES ({placeholders})", vals)
504
+ return cur.rowcount
505
+
506
+ def insert_job(self, job_id: str, status: str, **kwargs) -> bool:
507
+ """Create a new job row atomically; return False if the id is already taken.
508
+
509
+ Unlike `upsert_job` this never touches an existing record, so it is the
510
+ only write a fresh submission may make before it owns the row.
511
+ """
512
+ with self.lock(), self._conn() as conn, conn:
513
+ inserted = self._insert_job_row(
514
+ conn, job_id, status, time.time(), kwargs, verb="INSERT OR IGNORE"
515
+ )
516
+ return inserted == 1
502
517
 
503
518
  def get_job(self, job_id: str) -> dict | None:
504
519
  with self.lock(), self._conn() as conn:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: solvent-agent
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -78,3 +78,51 @@ def test_inline_script_json_cannot_close_script_tag(tmp_path, monkeypatch):
78
78
  assert "</script><script>globalThis" not in page
79
79
  # The data still round-trips as JSON for the client.
80
80
  assert json.loads(dashboard._script_json({"t": breakout}))["t"] == breakout
81
+
82
+
83
+ def test_inline_script_json_escapes_js_line_terminators():
84
+ encoded = dashboard._script_json({"t": "a
b
c"})
85
+ assert "
" not in encoded and "
" not in encoded
86
+ assert json.loads(encoded)["t"] == "a
b
c"
87
+
88
+
89
+ def _js_function(page: str, name: str) -> str:
90
+ start = page.index(f"function {name}(")
91
+ return page[start : page.index("\n }\n", start)]
92
+
93
+
94
+ def test_client_renders_job_fields_as_text_not_markup(tmp_path, monkeypatch):
95
+ monkeypatch.setenv("SOLVENT_HOME", str(tmp_path))
96
+ monkeypatch.setattr(dashboard, "OUT", tmp_path / "treasury_dashboard.html")
97
+ page = dashboard.render(_snapshot(), []).read_text()
98
+
99
+ for name in ("updateBriefsList", "updateDrawerContent"):
100
+ body = _js_function(page, name)
101
+ assert "textContent" in body
102
+ assert "metaHTML" not in body
103
+ assert "container.innerHTML = html" not in body
104
+ assert "onclick=" not in body
105
+
106
+
107
+ def test_rendered_inline_scripts_parse_as_javascript(tmp_path, monkeypatch):
108
+ import re
109
+ import shutil
110
+ import subprocess
111
+
112
+ import pytest
113
+
114
+ node = shutil.which("node")
115
+ if node is None:
116
+ pytest.skip("node is not installed")
117
+ monkeypatch.setenv("SOLVENT_HOME", str(tmp_path))
118
+ monkeypatch.setattr(dashboard, "OUT", tmp_path / "treasury_dashboard.html")
119
+ page = dashboard.render(_snapshot(), []).read_text()
120
+
121
+ scripts = re.findall(r"<script>(.*?)</script>", page, re.S)
122
+ assert scripts
123
+ for index, script in enumerate(scripts):
124
+ path = tmp_path / f"inline-{index}.js"
125
+ path.write_text(script, encoding="utf-8")
126
+ result = subprocess.run([node, "--check", str(path)], capture_output=True, text=True)
127
+ assert result.returncode == 0, result.stderr
128
+ assert "html.split(/\\n\\n+/)" in page
@@ -14,6 +14,7 @@ from solvent.export import (
14
14
  collect,
15
15
  format_summary,
16
16
  ledger_rows,
17
+ neutralise_cell,
17
18
  parse_period,
18
19
  period_summary,
19
20
  write_csv,
@@ -179,3 +180,50 @@ def test_format_summary_reports_the_close_and_the_files(treasury, tmp_path):
179
180
  assert "PERIOD CLOSE" in rendered
180
181
  assert "$49.00" in rendered
181
182
  assert "ledger.csv" in rendered
183
+
184
+
185
+ @pytest.mark.parametrize(
186
+ "raw",
187
+ [
188
+ "=HYPERLINK(\"http://evil.example\",\"x\")",
189
+ "+1+cmd|' /C calc'!A0",
190
+ "-2+3",
191
+ "@SUM(A1:A9)",
192
+ "\t=1+1",
193
+ "\r=1+1",
194
+ " =1+1",
195
+ "\x00\u200b=1+1",
196
+ ],
197
+ )
198
+ def test_formula_like_text_is_neutralised(raw):
199
+ assert neutralise_cell(raw) == "'" + raw
200
+
201
+
202
+ @pytest.mark.parametrize("value", ["a brief", "a@x.example", "", None, 4_900, -600, 12.5])
203
+ def test_ordinary_values_pass_through(value):
204
+ assert neutralise_cell(value) == value
205
+
206
+
207
+ def test_write_csv_neutralises_untrusted_text_but_keeps_numbers(tmp_path):
208
+ t = Treasury(path=tmp_path / "ledger.db")
209
+ t.seed(10_000)
210
+ t.upsert_job(
211
+ "J1",
212
+ "completed",
213
+ topic='=HYPERLINK("http://evil.example","open")',
214
+ budget_cents=4_900,
215
+ customer_email="a@x.example",
216
+ )
217
+ t.spend(600, "@SUM(1+1)", job_id="J1", vendor="-vendor")
218
+ write_csv(collect(t), tmp_path / "books")
219
+
220
+ with (tmp_path / "books" / "jobs.csv").open(encoding="utf-8") as fh:
221
+ job = next(csv.DictReader(fh))
222
+ assert job["topic"] == '\'=HYPERLINK("http://evil.example","open")'
223
+ assert job["budget_cents"] == "4900"
224
+
225
+ with (tmp_path / "books" / "ledger.csv").open(encoding="utf-8") as fh:
226
+ expense = next(r for r in csv.DictReader(fh) if r["kind"] == "expense")
227
+ assert expense["memo"] == "'@SUM(1+1)"
228
+ assert expense["vendor"] == "'-vendor"
229
+ assert expense["signed_cents"] == "-600"