pennyrouter 0.3.19 → 0.3.21

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.
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """Local, dependency-free browser for penny-compare reports.
3
+
4
+ Run with: python3 apps/compare/viewer.py
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sqlite3
11
+ from pathlib import Path
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ HERE = Path(__file__).resolve().parent
16
+ REPORTS = HERE / "reports"
17
+
18
+
19
+ def json_file(path: Path):
20
+ try:
21
+ return json.loads(path.read_text())
22
+ except (OSError, ValueError):
23
+ return None
24
+
25
+
26
+ def find_transcript(side: dict) -> Path | None:
27
+ direct = Path(side.get("transcript_path", "")) if side.get("transcript_path") else None
28
+ if direct and direct.exists():
29
+ return direct
30
+ sid, harness = side.get("session_id"), side.get("harness")
31
+ if harness == "opencode":
32
+ stored = side.get("transcript_path", "")
33
+ database = stored.split("#", 1)[0] if "#" in stored else str(Path.home() / ".local/share/opencode/opencode.db")
34
+ path = Path(database)
35
+ return path if path.exists() else None
36
+ if not sid:
37
+ return None
38
+ roots = []
39
+ if harness == "claude-code":
40
+ roots = [Path.home() / ".claude" / "projects"]
41
+ elif harness == "codex":
42
+ roots = [Path.home() / ".codex" / "sessions"]
43
+ for root in roots:
44
+ if not root.exists():
45
+ continue
46
+ for path in root.rglob("*.jsonl"):
47
+ if path.stem == sid or sid in path.name:
48
+ return path
49
+ if harness == "claude-code":
50
+ try:
51
+ with path.open() as handle:
52
+ first = handle.readline()
53
+ if json.loads(first).get("sessionId") == sid:
54
+ return path
55
+ except (OSError, ValueError):
56
+ pass
57
+ return None
58
+
59
+
60
+ def text_content(value) -> str:
61
+ if isinstance(value, str):
62
+ return value
63
+ if isinstance(value, list):
64
+ parts = []
65
+ for item in value:
66
+ if isinstance(item, str):
67
+ parts.append(item)
68
+ elif isinstance(item, dict):
69
+ if item.get("type") in ("text", "output_text", "input_text"):
70
+ parts.append(str(item.get("text") or item.get("content") or ""))
71
+ return "\n".join(p for p in parts if p)
72
+ return ""
73
+
74
+
75
+ def read_turns(side: dict) -> list[dict]:
76
+ if side.get("harness") == "opencode":
77
+ stored = side.get("transcript_path", "")
78
+ session_id = side.get("session_id") or (stored.split("#", 1)[1] if "#" in stored else "")
79
+ database = stored.split("#", 1)[0] if "#" in stored else str(Path.home() / ".local/share/opencode/opencode.db")
80
+ if not session_id:
81
+ return []
82
+ turns = []
83
+ try:
84
+ db = sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=0.2)
85
+ rows = db.execute(
86
+ "select data from part where session_id = ? order by time_created, id",
87
+ (session_id,),
88
+ )
89
+ for (raw,) in rows:
90
+ try:
91
+ obj = json.loads(raw)
92
+ except (TypeError, ValueError):
93
+ continue
94
+ if obj.get("type") != "text" or not str(obj.get("text") or "").strip():
95
+ continue
96
+ # OpenCode's text parts do not carry role; assistant parts have a time field,
97
+ # while the initial prompt is the only user text part in normal sessions.
98
+ role = "assistant" if obj.get("time") else "user"
99
+ turns.append({"role": role, "text": str(obj["text"]), "timestamp": None, "id": None})
100
+ db.close()
101
+ except (OSError, sqlite3.Error):
102
+ return []
103
+ return turns
104
+ path = find_transcript(side)
105
+ if not path:
106
+ return []
107
+ harness = side.get("harness")
108
+ turns = []
109
+ try:
110
+ lines = path.read_text().splitlines()
111
+ except OSError:
112
+ return turns
113
+ for raw in lines:
114
+ try:
115
+ obj = json.loads(raw)
116
+ except ValueError:
117
+ continue
118
+ payload = obj.get("payload") or {}
119
+ role, content = None, ""
120
+ if harness == "claude-code":
121
+ message = obj.get("message") or {}
122
+ role, content = message.get("role"), text_content(message.get("content"))
123
+ else:
124
+ if obj.get("type") == "response_item" and payload.get("type") == "message":
125
+ role, content = payload.get("role"), text_content(payload.get("content"))
126
+ elif obj.get("type") == "event_msg" and payload.get("type") == "agent_message":
127
+ role, content = "assistant", text_content(payload.get("message"))
128
+ if role not in ("user", "assistant") or not content.strip():
129
+ continue
130
+ turns.append({
131
+ "role": role,
132
+ "text": content,
133
+ "timestamp": obj.get("timestamp"),
134
+ "id": (obj.get("message", {}).get("id") if harness == "claude-code" else payload.get("id")),
135
+ })
136
+ return turns
137
+
138
+
139
+ def report_summaries():
140
+ result = []
141
+ for path in sorted(REPORTS.glob("compare-*.json"), reverse=True):
142
+ report = json_file(path)
143
+ if not report:
144
+ continue
145
+ result.append({
146
+ "id": path.name,
147
+ "created_at": report.get("created_at"),
148
+ "pricing_profile": report.get("pricing_profile"),
149
+ "left": report.get("left", {}), "right": report.get("right", {}),
150
+ "savings": report.get("savings_native_minus_penny", {}),
151
+ })
152
+ return result
153
+
154
+
155
+ def full_report(name: str):
156
+ if Path(name).name != name or not name.startswith("compare-"):
157
+ return None
158
+ report = json_file(REPORTS / name)
159
+ if not report:
160
+ return None
161
+ for side_name in ("left", "right"):
162
+ side = report.get(side_name, {})
163
+ transcript = find_transcript(side)
164
+ side["turns"] = read_turns(side)
165
+ side["transcript_available"] = bool(transcript)
166
+ side["transcript_path"] = str(transcript) if transcript else ""
167
+ return report
168
+
169
+
170
+ PAGE = r'''<!doctype html>
171
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
172
+ <title>Penny Compare</title><style>
173
+ :root{color-scheme:dark;--bg:#0d1117;--panel:#151c25;--line:#273443;--muted:#8b9aaa;--text:#edf4f7;--green:#55e6a5;--blue:#79b8ff;--amber:#ffc857}
174
+ *{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 85% -10%,#19332f 0,#0d1117 38%);color:var(--text);font:14px/1.5 Inter,ui-sans-serif,system-ui,sans-serif}main{max-width:1440px;margin:auto;padding:34px 28px 70px}h1{font-size:31px;margin:0;letter-spacing:-.04em}h2{font-size:18px;margin:0 0 15px}.eyebrow{color:var(--green);font-size:11px;font-weight:700;letter-spacing:.15em;text-transform:uppercase}.top{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:28px}.top p{color:var(--muted);margin:8px 0 0}.select{background:var(--panel);border:1px solid var(--line);color:var(--text);border-radius:9px;padding:10px 12px;min-width:310px}.panel{background:color-mix(in srgb,var(--panel) 92%,transparent);border:1px solid var(--line);border-radius:14px;padding:20px;box-shadow:0 15px 45px #0002}.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px}.metric{padding:16px;border:1px solid var(--line);border-radius:10px;background:#10161e}.metric label{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}.metric strong{display:block;font-size:23px;margin-top:5px}.good{color:var(--green)}.bad{color:#ff8a8a}.compare{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px}.sidehead{display:flex;justify-content:space-between;align-items:start;border-bottom:1px solid var(--line);padding-bottom:13px;margin-bottom:14px}.role{font-weight:750;font-size:17px}.model,.meta{color:var(--muted);font-size:12px}.statrow{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid #22303c}.statrow span:first-child{color:var(--muted)}.bar{height:7px;background:#26323c;border-radius:99px;margin-top:14px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--blue),var(--green));border-radius:inherit}.turngrid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.turns{display:flex;flex-direction:column;gap:7px;max-height:520px;overflow:auto}.turn{border:1px solid var(--line);background:#10161e;border-radius:9px;padding:11px 13px;cursor:pointer;text-align:left;color:var(--text)}.turn:hover,.turn.active{border-color:var(--green);background:#162620}.turntop{display:flex;justify-content:space-between;color:var(--muted);font-size:11px;text-transform:uppercase}.turntext{display:block;margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail{margin-top:14px;background:#0b1016;border:1px solid var(--line);border-radius:10px;padding:16px;min-height:145px}.detail pre{font:13px/1.6 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0 0;color:#d6e3e8}.empty{color:var(--muted);padding:25px 4px}.session{font:11px ui-monospace,monospace;color:var(--muted);word-break:break-all;margin-top:12px}@media(max-width:800px){main{padding:22px 14px}.top,.compare,.turngrid{display:block}.select{margin-top:18px;width:100%;min-width:0}.summary{grid-template-columns:1fr 1fr}.compare>.panel,.turngrid>.panel{margin-bottom:14px}}
175
+ </style></head><body><main><div class="top"><div><div class="eyebrow">Penny Router / Lab</div><h1>Comparison reports</h1><p>Inspect the cost, token shape, and actual transcript behind every run.</p></div><select id="reports" class="select"></select></div><div id="app"></div></main><script>
176
+ const $=s=>document.querySelector(s), fmt=n=>Number(n||0).toLocaleString(), money=n=>'$'+Math.abs(Number(n||0)).toFixed(4), money2=n=>'$'+Math.abs(Number(n||0)).toFixed(2), pct=n=>Math.abs(Number(n||0)).toFixed(0)+'%';
177
+ let report;
178
+ function savingCopy(value, percent, noun){const saved=Number(value||0)>=0;return {label:saved?'Penny '+noun:'Penny '+noun.replace('savings','cost'), amount:(saved?'':'−')+money(value), detail:pct(percent)+(saved?' less than native':' more than native')};}
179
+ function sideCard(side){const t=side.totals||{}, max=Math.max((report.left.totals||{}).total||0,(report.right.totals||{}).total||0,1);return `<section class="panel"><div class="sidehead"><div><div class="role">${esc(side.label||side.role||'Side')}</div><div class="model">${esc(side.variant||'')} · ${esc(side.harness||'')}</div></div><div class="eyebrow">${esc(side.role||'')}</div></div><div class="statrow"><span>Cost</span><b>${money(t.cost)}</b></div><div class="statrow"><span>Total tokens</span><b>${fmt(t.total)}</b></div><div class="statrow"><span>Input / cache read</span><b>${fmt(t.fresh_input)} / ${fmt(t.cache_read)}</b></div><div class="statrow"><span>Output</span><b>${fmt(t.output)}</b></div><div class="statrow"><span>Model calls</span><b>${fmt(side.call_count)}</b></div><div class="bar"><i style="width:${Math.max(2,(t.total||0)/max*100)}%"></i></div><div class="session"><span>session ${esc(side.session_id||'not recorded')}</span><span class="transcript-label">JSONL path</span><span class="transcript-path">${esc(side.transcript_path||'Transcript unavailable for this older report')}</span></div></section>`}
180
+ function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
181
+ function turns(side,which){let list=side.turns||[];return `<section class="panel"><div class="sidehead"><h2>${esc(side.label||which)} turns</h2><span class="meta">${list.length} messages</span></div><div class="turns">${list.length?list.map((x,i)=>`<button class="turn turn-${esc(x.role)}" aria-expanded="false" data-side="${which}" data-index="${i}"><span class="turntop"><span>${esc(x.timestamp||'')}</span></span><span class="turntext">${esc(x.text.slice(0,320))}${x.text.length>320?'…':''}</span></button>`).join(''):'<div class="empty">No linked JSONL transcript found.</div>'}</div></section>`}
182
+ function render(){let l=report.left||{},r=report.right||{},s=report.savings_native_minus_penny||{},c=savingCopy(s.cost,s.cost_percent,'savings'),t=savingCopy(s.tokens,s.token_percent,'token savings');$('#app').innerHTML=`<div class="summary"><div class="metric"><label>${c.label}</label><strong class="${s.cost>=0?'good':'bad'}">${s.cost>=0?'':'−'}${money2(s.cost)} (${pct(s.cost_percent)})</strong></div><div class="metric"><label>${t.label}</label><strong class="${s.tokens>=0?'good':'bad'}">${s.tokens>=0?'':'−'}${fmt(s.tokens)} (${pct(s.token_percent)})</strong></div><div class="metric"><label>Run date</label><strong>${esc(new Date(report.created_at).toLocaleDateString())}</strong><span class="meta">${esc(new Date(report.created_at).toLocaleTimeString())}</span></div><div class="metric"><label>Pricing</label><strong>${esc(report.pricing_profile||'standard')}</strong><span class="meta">${esc(l.call_count||0)} vs ${esc(r.call_count||0)} calls</span></div></div><div class="compare">${sideCard(l)}${sideCard(r)}</div><div class="turngrid">${turns(l,'left')}${turns(r,'right')}</div>`;document.querySelectorAll('.turn').forEach(b=>b.onclick=()=>{const side=report[b.dataset.side],x=side.turns[Number(b.dataset.index)],expanded=b.classList.toggle('expanded');document.querySelectorAll('.turn').forEach(other=>{if(other!==b){other.classList.remove('expanded','active');other.setAttribute('aria-expanded','false');const otherSide=report[other.dataset.side],otherTurn=otherSide.turns[Number(other.dataset.index)];other.querySelector('.turntext').textContent=otherTurn.text.slice(0,320)+(otherTurn.text.length>320?'…':'')}});b.classList.toggle('active',expanded);b.setAttribute('aria-expanded',String(expanded));b.querySelector('.turntext').textContent=expanded?x.text.slice(0):x.text.slice(0,320)+(x.text.length>320?'…':'')})}
183
+ async function load(){let list=await fetch('/api/reports').then(r=>r.json());$('#reports').innerHTML=list.map(x=>`<option value="${esc(x.id)}">${new Date(x.created_at).toLocaleString()} · ${esc(x.left.label||'left')} vs ${esc(x.right.label||'right')}</option>`).join('');if(!list.length){$('#app').innerHTML='<div class="panel empty">No reports found in apps/compare/reports.</div>';return}async function choose(id){report=await fetch('/api/report?id='+encodeURIComponent(id)).then(r=>r.json());render()}$('#reports').onchange=()=>choose($('#reports').value);choose(list[0].id)}load();
184
+ </script></body></html>'''
185
+
186
+
187
+ class Handler(BaseHTTPRequestHandler):
188
+ def send_json(self, value, status=200):
189
+ body = json.dumps(value).encode()
190
+ self.send_response(status); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
191
+ def do_GET(self):
192
+ parsed = urlparse(self.path)
193
+ if parsed.path == "/":
194
+ enhancement = '''<style>:root{color-scheme:light;--paper:#f3ede1;--paper-raised:#fbf7ef;--paper-sunk:#e8e0d0;--line:#d8cfbd;--line-strong:#b9ad96;--navy:#233044;--navy-soft:#3e4c61;--ink:#1c1814;--ink-soft:#4d473f;--ink-faint:#837a6e;--brick:#b23a2c;--brick-deep:#8f2b1f;--brick-tint:#f4e2dd;--green:#2f7d4f;--green-tint:#dcecdf}body{background-color:var(--paper);background-image:radial-gradient(rgba(28,24,20,.022) 1px,transparent 1px),radial-gradient(rgba(28,24,20,.018) 1px,transparent 1px);background-size:7px 7px,11px 11px;color:var(--ink);font-family:Arial,system-ui,sans-serif}.top p,.model,.meta,.barlabel{color:var(--ink-faint)}h1,h2{color:var(--navy)}.eyebrow{color:var(--brick)}.select{background:var(--paper-raised);border-color:var(--line-strong);color:var(--ink)}.panel,.metric{background:var(--paper-raised);border-color:var(--line);box-shadow:0 1px 2px rgba(28,24,20,.05),0 10px 28px -14px rgba(28,24,20,.18)}.statrow{border-color:var(--line)}.statrow span:first-child{color:var(--ink-soft)}.bar{background:var(--paper-sunk)}.bar i{background:linear-gradient(90deg,#2f7d4f,#d49b35 58%,#b23a2c)}.barlabel{font-size:12px;margin-top:7px}.session{display:none}.turn{background:var(--paper-raised);border-color:var(--line);color:var(--ink)}.turn:hover,.turn.active{border-color:var(--brick);background:var(--brick-tint)}.turntop{color:var(--ink-faint)}.detail{background:var(--paper-sunk);border-color:var(--line)}.detail pre{color:var(--ink)}.good{color:var(--green)}.bad{color:var(--brick)}</style><script>new MutationObserver(()=>document.querySelectorAll('.bar').forEach(bar=>{if(bar.nextElementSibling?.classList.contains('barlabel'))return;const row=[...bar.closest('.panel').querySelectorAll('.statrow')].find(x=>x.firstElementChild?.textContent==='Total tokens');if(!row)return;const label=document.createElement('div');label.className='barlabel';label.textContent='total tokens '+row.lastElementChild.textContent;bar.after(label)})).observe(document.documentElement,{childList:true,subtree:true});</script>'''
195
+ enhancement += '''<style>
196
+ .compare,.turngrid{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
197
+ .compare>.panel,.turngrid>.panel,.turns,.detail{min-width:0}
198
+ .turn{display:block;width:88%;min-width:0;border-color:var(--line);border-radius:16px;padding:13px 16px;transition:border-color .15s,box-shadow .15s}
199
+ .turn-user{align-self:flex-end;background:#e9f0fb;border-color:#d8e3f2;color:#172b4d}
200
+ .turn-assistant{align-self:flex-start;background:var(--paper-raised)}
201
+ .turn:hover,.turn.active{border-color:var(--line-strong);background:inherit;box-shadow:0 0 0 2px rgba(62,76,97,.08)}
202
+ .turn-user:hover,.turn-user.active{background:#e9f0fb}
203
+ .turntop{justify-content:flex-end;gap:12px;min-width:0}.turntop span{min-width:0;overflow-wrap:anywhere;font-size:10px}
204
+ .turntext{white-space:normal;overflow-wrap:anywhere;word-break:break-word}
205
+ .session{display:grid;gap:4px;word-break:break-word;overflow-wrap:anywhere}
206
+ .transcript-label{font:10px Arial,system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-faint)}
207
+ .transcript-path{display:block}
208
+ @media(max-width:800px){.compare,.turngrid{display:block}}
209
+ </style>'''
210
+ enhancement += '''<script>new MutationObserver(()=>document.querySelectorAll('.bar i').forEach(fill=>{const width=parseFloat(fill.style.width);if(width>0)fill.style.backgroundSize=(10000/width)+'% 100%'})).observe(document.documentElement,{childList:true,subtree:true});</script>'''
211
+ body = PAGE.replace("</head>", enhancement + "</head>").encode(); self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8"); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body); return
212
+ if parsed.path == "/api/reports": self.send_json(report_summaries()); return
213
+ if parsed.path == "/api/report":
214
+ data = full_report(parse_qs(parsed.query).get("id", [""])[0]); self.send_json(data or {"error": "report not found"}, 404 if not data else 200); return
215
+ self.send_error(404)
216
+ def log_message(self, fmt, *args): pass
217
+
218
+
219
+ def main():
220
+ global REPORTS
221
+ parser = argparse.ArgumentParser(description="View penny-compare reports in a browser")
222
+ parser.add_argument("--port", type=int, default=8788)
223
+ parser.add_argument("--reports", type=Path, default=REPORTS)
224
+ args = parser.parse_args()
225
+ REPORTS = args.reports.resolve()
226
+ server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
227
+ print(f"Penny Compare viewer: http://127.0.0.1:{args.port}")
228
+ try: server.serve_forever()
229
+ except KeyboardInterrupt: pass
230
+
231
+
232
+ if __name__ == "__main__": main()