claude-finops 0.17.1 → 0.18.1
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.
- package/README.md +5 -2
- package/finops/analytics.py +22 -0
- package/finops/api.py +7 -0
- package/finops/etl.py +37 -0
- package/finops/procs.py +19 -2
- package/package.json +1 -1
- package/web/app.js +15 -1
package/README.md
CHANGED
|
@@ -135,7 +135,6 @@ rather than guessing:
|
|
|
135
135
|
- Plan tier, allowance, and remaining credits
|
|
136
136
|
- Message / request allowances
|
|
137
137
|
- Billed invoice amounts
|
|
138
|
-
- Assistant response text (only usage metadata is extracted)
|
|
139
138
|
- Lines changed, commits, pull requests, bugs fixed
|
|
140
139
|
|
|
141
140
|
Wherever one of these would appear, you get **"Unavailable from connected Claude
|
|
@@ -387,7 +386,11 @@ if the Claude desktop app is installed, its Cowork transcripts and
|
|
|
387
386
|
server binds to `127.0.0.1` only, but treat the database and any export you
|
|
388
387
|
generate as sensitive.
|
|
389
388
|
|
|
390
|
-
|
|
389
|
+
Claude's replies are **not** stored in the database. A prompt's detail panel reads the reply
|
|
390
|
+
straight from that session's transcript in `~/.claude/projects` when you open it, and says so
|
|
391
|
+
if the transcript has been deleted.
|
|
392
|
+
|
|
393
|
+
**Hide prompts** (the eye icon in the top bar) hides prompt text, Claude's replies and session titles as dots,
|
|
391
394
|
for when you share your screen. Session titles fall back to their short id. The
|
|
392
395
|
setting is remembered in that browser. The side panel has the same button, and using it there
|
|
393
396
|
keeps the panel open. It only changes what the page shows: the
|
package/finops/analytics.py
CHANGED
|
@@ -818,6 +818,28 @@ class Analytics:
|
|
|
818
818
|
p["category_evidence"] = []
|
|
819
819
|
return p
|
|
820
820
|
|
|
821
|
+
def prompt_reply(self, pid):
|
|
822
|
+
"""Claude's reply to one prompt, read from the transcript when asked for."""
|
|
823
|
+
from . import guard as G
|
|
824
|
+
from .etl import DEFAULT_SOURCE, reply_from_transcript
|
|
825
|
+
p = self.one("SELECT pr.uuid, pr.session_id, s.source_file FROM prompts pr"
|
|
826
|
+
" LEFT JOIN sessions s ON s.id=pr.session_id WHERE pr.id=?", (pid,))
|
|
827
|
+
if not p:
|
|
828
|
+
return {"error": "not found"}
|
|
829
|
+
path = p["source_file"]
|
|
830
|
+
if not path or not os.path.exists(path):
|
|
831
|
+
path = G.find_transcript(p["session_id"],
|
|
832
|
+
[os.environ.get("CLAUDE_PROJECTS") or DEFAULT_SOURCE])
|
|
833
|
+
if not p["uuid"] or not path:
|
|
834
|
+
return {"ok": False, "reason": "The transcript for this session is no longer on disk."}
|
|
835
|
+
try:
|
|
836
|
+
r = reply_from_transcript(path, p["uuid"])
|
|
837
|
+
except OSError:
|
|
838
|
+
return {"ok": False, "reason": "The transcript could not be read."}
|
|
839
|
+
if not r["found"]:
|
|
840
|
+
return {"ok": False, "reason": "This prompt was not found in its transcript."}
|
|
841
|
+
return {"ok": True, **r}
|
|
842
|
+
|
|
821
843
|
def session_detail(self, sid, f=None):
|
|
822
844
|
s = self.one("""SELECT s.*, pr.name project FROM sessions s
|
|
823
845
|
JOIN projects pr ON pr.id=s.project_id WHERE s.id=?""", (sid,))
|
package/finops/api.py
CHANGED
|
@@ -611,6 +611,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
611
611
|
raise BadRequest("id must be an integer")
|
|
612
612
|
d = a.prompt_detail(pid, f)
|
|
613
613
|
return self.send_json(d, 404 if isinstance(d, dict) and d.get("error") else 200)
|
|
614
|
+
if route.startswith("prompt_reply/"):
|
|
615
|
+
try:
|
|
616
|
+
pid = int(route.split("/")[1])
|
|
617
|
+
except ValueError:
|
|
618
|
+
raise BadRequest("id must be an integer")
|
|
619
|
+
d = a.prompt_reply(pid)
|
|
620
|
+
return self.send_json(d, 404 if d.get("error") else 200)
|
|
614
621
|
if route.startswith("session/"):
|
|
615
622
|
d = a.session_detail(route.split("/", 1)[1], f)
|
|
616
623
|
return self.send_json(d, 404 if isinstance(d, dict) and d.get("error") else 200)
|
package/finops/etl.py
CHANGED
|
@@ -183,6 +183,43 @@ def _text_of(content):
|
|
|
183
183
|
return "\n".join(x for x in out if x)
|
|
184
184
|
|
|
185
185
|
|
|
186
|
+
def _is_human_prompt(r):
|
|
187
|
+
"""A user line that load_file counts as a prompt (not a tool result, meta or injected text)."""
|
|
188
|
+
if r.get("type") != "user" or r.get("toolUseResult") is not None or r.get("isMeta"):
|
|
189
|
+
return False
|
|
190
|
+
text = _text_of((r.get("message") or {}).get("content"))
|
|
191
|
+
return bool(text.strip()) and not text.lstrip().startswith(INJECTED_PREFIXES)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
REPLY_MAX_CHARS = 60_000
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def reply_from_transcript(path, prompt_uuid):
|
|
198
|
+
"""Claude's text replies to one prompt, read from its transcript on demand (never stored).
|
|
199
|
+
Returns the text blocks in order, from the prompt up to the next human prompt."""
|
|
200
|
+
parts, found = [], False
|
|
201
|
+
with open(path, errors="replace") as fh:
|
|
202
|
+
for line in fh:
|
|
203
|
+
if not found and prompt_uuid not in line:
|
|
204
|
+
continue
|
|
205
|
+
try:
|
|
206
|
+
r = json.loads(line)
|
|
207
|
+
except json.JSONDecodeError:
|
|
208
|
+
continue
|
|
209
|
+
if not found:
|
|
210
|
+
found = r.get("uuid") == prompt_uuid
|
|
211
|
+
continue
|
|
212
|
+
if _is_human_prompt(r):
|
|
213
|
+
break
|
|
214
|
+
if r.get("type") == "assistant" and not r.get("isSidechain"):
|
|
215
|
+
text = _text_of((r.get("message") or {}).get("content")).strip()
|
|
216
|
+
if text:
|
|
217
|
+
parts.append(text)
|
|
218
|
+
text = "\n\n".join(parts)
|
|
219
|
+
return {"found": found, "parts": len(parts), "text": text[:REPLY_MAX_CHARS],
|
|
220
|
+
"truncated": len(text) > REPLY_MAX_CHARS}
|
|
221
|
+
|
|
222
|
+
|
|
186
223
|
IMAGE_CHARS = 1600 * 4 # an image costs ~1.6K tokens, not its base64 length
|
|
187
224
|
|
|
188
225
|
|
package/finops/procs.py
CHANGED
|
@@ -71,10 +71,27 @@ def _is_claude(p):
|
|
|
71
71
|
return base in ("claude", "claude.exe", "claude.cmd") or cmd.endswith("/claude")
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def _proc_starttime(pid):
|
|
75
|
+
"""Linux: a process's start time in clock ticks since boot (field 22 of /proc/<pid>/stat)."""
|
|
76
|
+
try:
|
|
77
|
+
with open(f"/proc/{int(pid)}/stat") as fh:
|
|
78
|
+
stat = fh.read()
|
|
79
|
+
except (OSError, TypeError, ValueError):
|
|
80
|
+
return None
|
|
81
|
+
fields = stat[stat.rfind(")") + 2:].split() # the name in (...) may contain spaces
|
|
82
|
+
return fields[19] if len(fields) > 19 else None
|
|
83
|
+
|
|
84
|
+
|
|
74
85
|
def _same_start(reg, p):
|
|
75
86
|
"""True unless the registry's procStart names a different start time than ps reports.
|
|
76
|
-
Whitespace is normalised: the registry pads single-digit days ("Oct 9"), ps output is re-joined ("Oct 9").
|
|
77
|
-
|
|
87
|
+
Whitespace is normalised: the registry pads single-digit days ("Oct 9"), ps output is re-joined ("Oct 9").
|
|
88
|
+
On Linux, Claude Code records clock ticks since boot ("119623") instead, read from /proc."""
|
|
89
|
+
start = reg.get("procStart")
|
|
90
|
+
if not start:
|
|
91
|
+
return True
|
|
92
|
+
if start.strip().isdigit():
|
|
93
|
+
return _proc_starttime(reg.get("pid")) == start.strip()
|
|
94
|
+
return start.split() == p["lstart"].split()
|
|
78
95
|
|
|
79
96
|
|
|
80
97
|
def _ancestors(pid, procs):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-finops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-finops": "bin/claude-finops.js"
|
package/web/app.js
CHANGED
|
@@ -608,6 +608,8 @@ async function openPrompt(id) {
|
|
|
608
608
|
<span>${fmtInt(p.char_len)} chars · ${fmtInt(p.word_len)} words</span>
|
|
609
609
|
${p.source ? `<span class="pill">${esc(p.source)}</span>` : ''}
|
|
610
610
|
</div>`, {badge: BADGE.actual})}
|
|
611
|
+
${card('Claude\'s response', '<div id="pr-reply"><div class="note">Reading the transcript…</div></div>',
|
|
612
|
+
{badge: BADGE.actual, hint: 'read from the transcript when you open this; not stored'})}
|
|
611
613
|
${card('Requests in this turn', table([
|
|
612
614
|
{h: 'Time', f: r => `<span class="mono">${esc((r.ts||'').slice(11,19))}</span>`},
|
|
613
615
|
{h: 'Model', f: r => `<span class="swatch" style="background:${modelColor(r.model)}"></span>${esc(modelName(r.model))}`},
|
|
@@ -638,10 +640,22 @@ async function openPrompt(id) {
|
|
|
638
640
|
<dt>Models</dt><dd>${esc(p.models || '—')}</dd>
|
|
639
641
|
<dt>Tool calls</dt><dd>${fmtInt(p.tool_calls)}</dd>
|
|
640
642
|
<dt>Avg latency</dt><dd>${p.latency_ms ? (p.latency_ms/1000).toFixed(1)+'s' : NA()}</dd>
|
|
641
|
-
<dt>Response text</dt><dd>${NA()} — transcripts are parsed for usage metadata, not stored replies</dd>
|
|
642
643
|
</dl>`)}`;
|
|
643
644
|
const link = d.querySelector('[data-sess]');
|
|
644
645
|
if (link) link.onclick = () => openSession(link.dataset.sess);
|
|
646
|
+
drawReply(d.querySelector('#pr-reply'), id);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function drawReply(box, id) {
|
|
650
|
+
let r;
|
|
651
|
+
try { r = await fetch(`/api/prompt_reply/${id}`).then(x => x.json()); }
|
|
652
|
+
catch (e) { r = {ok: false, reason: e.message}; }
|
|
653
|
+
if (!box.isConnected) return; // drawer closed or replaced while reading
|
|
654
|
+
if (!r.ok) { box.innerHTML = `<div class="na">${esc(r.reason || r.error || 'Could not read the reply.')}</div>`; return; }
|
|
655
|
+
box.innerHTML = r.text
|
|
656
|
+
? `<div class="prompt-text">${esc(pt(r.text))}</div>${r.truncated
|
|
657
|
+
? '<div class="note" style="margin-top:6px">Long reply: only the first part is shown.</div>' : ''}`
|
|
658
|
+
: '<div class="na">No text reply: Claude only ran tools for this prompt.</div>';
|
|
645
659
|
}
|
|
646
660
|
|
|
647
661
|
async function openSession(id) {
|