Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion demo_guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,6 @@
print(f" {C_GREEN}✅ ALLOWED{C_RESET} ${amt/100:>6.2f} → {vendor:<16} | {C_BOLD}{label}{C_RESET}")
except GuardrailError as e:
print(f" {C_RED}🛑 BLOCKED{C_RESET} ${amt/100:>6.2f} → {vendor:<16} | {label}\n {C_GREY}reason:{C_RESET} {C_RED}{e}{C_RESET}")

print(BAR)
print(" Every outbound payment is screened by guardrails before reaching Stripe.\n")
3 changes: 2 additions & 1 deletion solvent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ def main():
t = Treasury()
s = SolventStages(treasury=t, guard=Guardrails(t), stripe=StripeClient())
result = s.retry_job(job_id)
import json; print(json.dumps(result, indent=2, default=str))
import json
print(json.dumps(result, indent=2, default=str))
elif len(sys.argv) > 1 and sys.argv[1] in ("finance", "report"):
sys.argv.pop(1)
from .finance import main as finance_main
Expand Down
1 change: 0 additions & 1 deletion solvent/channels/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None


def main():
import asyncio
app = build_application()
print("SOLVENT Telegram bot starting (long-poll)...")
app.run_polling(allowed_updates=["message"])
Expand Down
48 changes: 24 additions & 24 deletions solvent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from solvent.treasury import fmt
from solvent import dashboard
from solvent.config import (
SolventConfig,
apply_config,
config_exists,
default_config,
Expand Down Expand Up @@ -63,42 +64,42 @@ def print_event(e: dict):
st = e.get("stage")
jid = e.get("job_id", "")
prefix = f"{C_CYAN}[{jid}]{C_RESET}"

if st == "quote":
verdict = f"{C_GREEN}ACCEPT{C_RESET}" if e["accept"] else f"{C_RED}DECLINE{C_RESET}"
print(f" ⚖️ {prefix} Margin Gate: price {C_BOLD}{fmt(e['price'])}{C_RESET} | est cost {fmt(e['est_cost'])} | projected margin {e['margin_pct']}% → {verdict}")
time.sleep(0.4)

elif st == "declined":
print(f" ✋ {prefix} {C_RED}Declined:{C_RESET} {e['reason']}")
time.sleep(0.4)

elif st == "invoice":
show_spinner(0.6, f"Generating Stripe Payment Link for {jid}...")
tag = "simulated" if e["simulated"] else "LIVE"
print(f" 📄 {prefix} Issued checkout link [{tag}]")
print(f" {C_GREY}URL:{C_RESET} {C_BLUE}{e['url']}{C_RESET}")
time.sleep(0.4)

elif st == "paid":
show_spinner(0.8, f"Polling Stripe invoice confirmation for {jid}...")
print(f" 💵 {prefix} {C_GREEN}Stripe Confirmed:{C_RESET} Payment of {C_GREEN}+{fmt(e['amount'])}{C_RESET} received")
time.sleep(0.4)

elif st == "fulfilled":
show_spinner(1.2, f"Nemotron compiling sell-side research brief for {jid}...")
filename = Path(e['deliverable']).name
print(f" 📝 {prefix} {C_GREEN}Fulfillment Finished:{C_RESET} Deliverable saved to {C_BLUE}data/reports/{filename}{C_RESET} ({e['tokens']} tokens)")
time.sleep(0.4)

elif st == "spend":
time.sleep(0.3)
print(f" 🛡️ {C_GREY}↳ Spend Approved:{C_RESET} Scoped payment {C_RED}−{fmt(e['amount'])}{C_RESET} to {C_BOLD}{e['vendor']}{C_RESET} ({e['memo']})")

elif st == "spend_blocked":
time.sleep(0.3)
print(f" 🛑 {C_RED}↳ Spend BLOCKED:{C_RESET} Guardrail rejected transaction of {fmt(e['amount'])} to {e['vendor']} ({e['memo']})")

elif st == "refunded":
time.sleep(0.3)
print(f" ↩️ {prefix} {C_RED}Escrow Refunded:{C_RESET} Returned {C_RED}{fmt(e['amount'])}{C_RESET} to customer ({e['reason']})")
Expand All @@ -119,7 +120,7 @@ def print_results(snap: dict):
print(f" Operating spend {C_RED}{fmt(snap['expense_cents'])}{C_RESET}")
print(f" Net profit {C_GREEN if snap['net_profit_cents']>=0 else C_RED}{fmt(snap['net_profit_cents'])}{C_RESET} ({snap['margin_pct']}% margin)")
print(f" Cash balance {C_YELLOW}{fmt(snap['balance_cents'])}{C_RESET} (seed was {fmt(snap['capital_cents'])})")

grew = snap['balance_cents'] - snap['capital_cents']
if grew > 0:
print(f" {C_GREEN}→ The agent grew its own treasury by {fmt(grew)} this session!{C_RESET}")
Expand All @@ -133,15 +134,15 @@ def run_batch_demo(seed_cents: int = 10_000, fresh: bool = True):
"""Run the standard batch demo of 4 predefined jobs."""
print(f"\n🪙 {C_BOLD}SOLVENT — Standard Batch Run (4 Inbound Jobs){C_RESET}")
print(BAR)

agent = Solvent(seed_cents=seed_cents, fresh=fresh, on_event=print_event)
print(f" Seed capital: {C_YELLOW}{fmt(agent.t.capital_cents())}{C_RESET}\n")

for job in SAMPLE_JOBS:
print(f"{C_BOLD}■ {job['id']}: {job['topic']}{C_RESET}")
show_spinner(0.4, "Analyzing inbound job specifications...")
agent.handle_job(job)

snap = agent.t.snapshot()
print_results(snap)
path = dashboard.render(snap, agent.log)
Expand All @@ -152,19 +153,19 @@ def run_interactive_mode(seed_cents: int = 10_000, fresh: bool = True):
"""Run interactive CLI mode letting the user test custom briefs and pricing."""
print(f"\n🪙 {C_BOLD}SOLVENT — Interactive Agent Terminal{C_RESET}")
print(BAR)

agent = Solvent(seed_cents=seed_cents, fresh=fresh, on_event=print_event)
print(f" Current balance: {C_YELLOW}{fmt(agent.t.balance_cents())}{C_RESET}")
print(f" {C_GREY}Tip: Type /fund <amount> (e.g. /fund 100) to add funds to the treasury.{C_RESET}\n")

job_index = 1
while True:
print(f"{C_BOLD}--- Enter New Research Request ---{C_RESET}")
topic = input(f"{C_CYAN}Topic (or /fund <amount>):{C_RESET} ").strip()
if not topic:
print(f"{C_RED}Request topic cannot be blank.{C_RESET}\n")
continue

if topic.startswith("/fund"):
parts = topic.split()
if len(parts) < 2:
Expand Down Expand Up @@ -193,7 +194,7 @@ def run_interactive_mode(seed_cents: int = 10_000, fresh: bool = True):
except ValueError:
print(f"{C_RED}Invalid amount. Usage: /fund <amount_in_usd>{C_RESET}\n")
continue

budget_str = input(f"{C_CYAN}Client Budget in USD (e.g. 50.00):{C_RESET} $").strip()
try:
budget_cents = int(float(budget_str) * 100)
Expand All @@ -203,16 +204,16 @@ def run_interactive_mode(seed_cents: int = 10_000, fresh: bool = True):
except ValueError:
print(f"{C_RED}Invalid numeric entry. Use standard formats like 49.00.{C_RESET}\n")
continue

job_id = f"I{job_index}"
job_index += 1

# Scale resource specifications based on budget
is_large = budget_cents >= 5000
est_tokens = 12000 if is_large else 7500
market_calls = 3 if is_large else 2
search_calls = 9 if is_large else 6

custom_job = {
"id": job_id,
"topic": topic,
Expand All @@ -223,25 +224,24 @@ def run_interactive_mode(seed_cents: int = 10_000, fresh: bool = True):
"market_data_calls": market_calls,
"web_search_calls": search_calls
}

print(f"\n{C_BOLD}■ {job_id}: {topic}{C_RESET}")
show_spinner(0.4, "Analyzing inbound job specifications...")
agent.handle_job(custom_job)

cont = input(f"Submit another research request? ({C_BOLD}y/N{C_RESET}): ").strip().lower()
print()
if cont != "y":
break

snap = agent.t.snapshot()
print_results(snap)
path = dashboard.render(snap, agent.log)
print(f"\n Dashboard: {C_BLUE}{path}{C_RESET}\n{BAR}\n")


def resolve_config(args) -> "SolventConfig":
def resolve_config(args) -> SolventConfig:
"""Load, onboard, or default user preferences."""
from solvent.config import SolventConfig

if wants_reconfigure():
return run_wizard()
Expand Down
50 changes: 25 additions & 25 deletions solvent/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ def generate_svg_chart(points: list[int]) -> str:
"""Generate an inline, styled SVG path representing running balance."""
if not points:
return ""

N = len(points)
width = 600
height = 140
min_v = min(points)
max_v = max(points)

# Breathing room (padding)
val_range = max_v - min_v
padding = val_range * 0.15 if val_range != 0 else 1000
Expand All @@ -57,25 +57,25 @@ def generate_svg_chart(points: list[int]) -> str:
bottom_limit = min_v - padding
top_limit = max_v + padding
v_range = top_limit - bottom_limit

coords = []
for i, val in enumerate(points):
x = i * (width / (N - 1)) if N > 1 else width / 2
# Invert Y coordinate since SVG y=0 is top
y = (height - 15) - ((val - bottom_limit) / v_range * (height - 30))
coords.append((x, y))

path_data = " ".join(f"{'M' if i == 0 else 'L'} {x:.1f},{y:.1f}" for i, (x, y) in enumerate(coords))
area_data = f"M {coords[0][0]:.1f},{height:.1f} " + " ".join(f"L {x:.1f},{y:.1f}" for x, y in coords) + f" L {coords[-1][0]:.1f},{height:.1f} Z"

dots = "".join(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="4.5" class="chart-dot" data-val="{p}" onclick="alert(\'Balance: \' + formatCents({p}))"><title>Balance: {fmt(p)}</title></circle>' for p, (x, y) in zip(points, coords))

# Subtle dashed guidelines in the background
guidelines = ""
for h_pct in [0.25, 0.5, 0.75]:
y_g = 15 + h_pct * (height - 30)
guidelines += f'<line x1="0" y1="{y_g:.1f}" x2="{width}" y2="{y_g:.1f}" stroke="var(--color-border)" stroke-width="0.8" stroke-dasharray="4,4" opacity="0.3" />'

svg = f"""
<svg viewBox="0 0 {width} {height}" class="line-chart" preserveAspectRatio="none">
<defs>
Expand All @@ -95,7 +95,7 @@ def generate_svg_chart(points: list[int]) -> str:

def build_status_data(snapshot: dict, log: list[dict]) -> dict:
s = snapshot

# 1. Read generated briefs from reports folder
briefs = {}
reports_path = reports_dir()
Expand All @@ -117,7 +117,7 @@ def build_status_data(snapshot: dict, log: list[dict]) -> dict:
elif e["kind"] == "expense":
running_balance -= e["amount_cents"]
balance_points.append(running_balance)

chart_svg = generate_svg_chart(balance_points)

# 3. Calculate vendor expenses dynamically
Expand All @@ -129,7 +129,7 @@ def build_status_data(snapshot: dict, log: list[dict]) -> dict:
amount = e["amount_cents"]
vendor_spends[vendor] = vendor_spends.get(vendor, 0) + amount
total_expense += amount

sorted_vendors = sorted(vendor_spends.items(), key=lambda x: x[1], reverse=True)
expense_breakdown_html = ""
for vendor, amt in sorted_vendors:
Expand Down Expand Up @@ -193,7 +193,7 @@ def build_status_data(snapshot: dict, log: list[dict]) -> dict:
"expenses": [],
"pnl": 0
})

for e in log:
jid = e.get("job_id")
if not jid:
Expand Down Expand Up @@ -236,33 +236,33 @@ def build_status_data(snapshot: dict, log: list[dict]) -> dict:
for jid, job in sorted(jobs_data.items()):
if not job["id"]:
continue

status_class = h(job["status"])
status_label = h(job["status"].replace("_", " ").upper())
jid_text = h(jid)
jid_js_arg = h(json.dumps(str(jid)))
job_title = h(job["title"] or "No Topic Provided")

expense_total = sum(x["amount"] for x in job["expenses"])
fin_pnl = job["price"] - expense_total if job["status"] == "completed" else 0
margin_str = f"{job['margin_pct']}%" if job["margin_pct"] else "N/A"

btn_html = ""
if job["status"] == "completed":
btn_html = f"""<button class="btn btn-primary btn-sm" onclick="openBriefModal({jid_js_arg})">View Brief</button>"""
elif job["status"] == "declined":
btn_html = f"""<span class="decline-label">Declined: {h(job['reason'])}</span>"""
elif job["status"] == "awaiting_payment":
btn_html = f"""<a href="{safe_href(job['invoice_url'])}" target="_blank" rel="noopener noreferrer" class="btn btn-outline btn-sm">Pay Invoice</a>"""

job_cards_html += f"""
<div class="job-card status-{status_class}">
<div class="job-card-header">
<span class="job-id">{jid_text}</span>
<span class="badge badge-{status_class}">{status_label}</span>
</div>
<h3 class="job-title">{job_title}</h3>

<div class="job-metrics">
<div class="job-metric-item">
<span class="job-metric-lbl">Budget</span>
Expand All @@ -281,7 +281,7 @@ def build_status_data(snapshot: dict, log: list[dict]) -> dict:
<span class="job-metric-val">{margin_str}</span>
</div>
</div>

<div class="job-card-footer">
{btn_html}
</div>
Expand All @@ -294,7 +294,7 @@ def build_status_data(snapshot: dict, log: list[dict]) -> dict:
ts_str = time.strftime("%H:%M:%S", time.localtime(e.get("ts", time.time())))
st = e["stage"]
job_ref = f"<span class='console-job-id'>[{h(e.get('job_id'))}]</span>" if e.get("job_id") else ""

if st == "quote":
verdict = "<span class='green-txt'>ACCEPT</span>" if e["accept"] else "<span class='red-txt'>DECLINE</span>"
msg = f"Quoting topic: \"{h(e['title'])}\" | Price: {fmt(e['price'])} | Est. Cost: {fmt(e['est_cost'])} | Margin: {h(e['margin_pct'])}% → {verdict}"
Expand Down Expand Up @@ -322,7 +322,7 @@ def build_status_data(snapshot: dict, log: list[dict]) -> dict:
msg = f"✓ Booked P&L. Job net: <span class='{pnl_color}'>{pnl_sign}{fmt(e['job_pnl'])}</span> | Current Capital Balance: <span class='gold-txt'>{fmt(e['balance'])}</span>"
else:
msg = h(e)

console_log_html += f"""
<div class="console-line">
<span class="console-time">{ts_str}</span>
Expand Down Expand Up @@ -967,7 +967,7 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
.grey-txt {{ color: var(--color-text-muted); }}
.gold-txt {{ color: var(--color-warning); }}
.filepath-txt {{ color: #38bdf8; font-weight: 500; }}

.vendor-badge {{
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--color-border);
Expand Down Expand Up @@ -1099,7 +1099,7 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
text-align: center;
padding: 16px 0;
}}

/* Connection Status Indicator */
.status-dot {{
display: inline-block;
Expand Down Expand Up @@ -1227,13 +1227,13 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
</div>

<div class="dashboard-body">

<div class="panel">
<h2>Financial Performance <span style="font-size:11px;color:var(--color-text-muted);font-weight:normal">cumulative ledger balance</span></h2>
<div class="chart-container" id="chart-container">
{chart_svg}
</div>

<h2 style="margin-top:24px;border-top:1px solid var(--color-border);padding-top:16px">Resource Allocation Breakdown</h2>
<div class="vendor-breakdown-list" id="expense-breakdown">
{expense_breakdown_html}
Expand Down Expand Up @@ -1313,13 +1313,13 @@ def render(snapshot: dict, log: list[dict], *, live: bool = False) -> Path:
function updateBriefsList() {{
const container = document.getElementById("briefs-list-container");
if (!container) return;

const jobIds = Object.keys(briefs || {{}});
if (jobIds.length === 0) {{
container.innerHTML = "<p class='no-data-msg'>No research briefs delivered yet.</p>";
return;
}}

let html = "";
jobIds.forEach(jobId => {{
const job = jobsData[jobId] || {{}};
Expand Down
Loading
Loading