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 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
- **Hide prompts** (the eye icon in the top bar) hides prompt text and session titles as dots,
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
@@ -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
- return not reg.get("procStart") or reg["procStart"].split() == p["lstart"].split()
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.17.1",
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) {