claude-mem 13.26.1 → 13.27.0
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/.codex-plugin/plugin.json +1 -1
- package/dist/npx-cli/index.js +1 -1
- package/openclaw/openclaw.plugin.json +1 -1
- package/openclaw/skills/agent-cost-report/CHECKSUMS.txt +47 -0
- package/openclaw/skills/agent-cost-report/SKILL.md +240 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/__init__.py +1 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/behavior.py +313 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/classify.py +83 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/costs.py +121 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/devices.py +88 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/evidence.py +165 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/labels.py +112 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/measure.py +89 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/mistakes.py +321 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/patterns.py +394 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/period.py +101 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/prices.py +102 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/render.py +516 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/rollup.py +364 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/rules.json +26 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/rules.py +78 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/sample.py +45 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/snapshot.py +55 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/sync.py +78 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/transcripts.py +130 -0
- package/openclaw/skills/agent-cost-report/scripts/acr/wins.py +286 -0
- package/openclaw/skills/agent-cost-report/scripts/acr.py +206 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/_db.py +49 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/_paths.py +9 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/codex/sessions/2026/09/18/rollout-codex-1.jsonl +4 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/make_report_fixtures.py +46 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/prices_sample.json +6 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/projects/-workspace-proj-a/sess-aaaa.jsonl +8 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/projects/-workspace-proj-b/sess-bbbb.jsonl +1 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-1day.json +823 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-30day.json +2213 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-7day.json +1252 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-empty.json +536 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/fixtures/report-session.json +662 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_behavior.py +275 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_devices.py +99 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_labels.py +70 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_measure.py +118 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_period.py +91 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_prices.py +126 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_render.py +114 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_rollup.py +264 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_snapshot.py +64 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_sync.py +52 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_transcripts.py +154 -0
- package/openclaw/skills/agent-cost-report/scripts/tests/test_wins.py +181 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/package.json +1 -1
- package/plugin/scripts/mcp-server.cjs +1 -1
- package/plugin/scripts/server-service.cjs +2 -2
- package/plugin/scripts/transcript-watcher.cjs +2 -2
- package/plugin/scripts/transcript-watcher.cjs.map +1 -1
- package/plugin/scripts/worker-service.cjs +5 -5
- package/plugin/scripts/worker-service.cjs.map +1 -1
- package/plugin/skills/agent-cost-report/CHECKSUMS.txt +47 -0
- package/plugin/skills/agent-cost-report/SKILL.md +240 -0
- package/plugin/skills/agent-cost-report/VERIFICATION.md +106 -0
- package/plugin/skills/agent-cost-report/scripts/acr/__init__.py +1 -0
- package/plugin/skills/agent-cost-report/scripts/acr/behavior.py +313 -0
- package/plugin/skills/agent-cost-report/scripts/acr/classify.py +83 -0
- package/plugin/skills/agent-cost-report/scripts/acr/costs.py +121 -0
- package/plugin/skills/agent-cost-report/scripts/acr/devices.py +88 -0
- package/plugin/skills/agent-cost-report/scripts/acr/evidence.py +165 -0
- package/plugin/skills/agent-cost-report/scripts/acr/labels.py +112 -0
- package/plugin/skills/agent-cost-report/scripts/acr/measure.py +89 -0
- package/plugin/skills/agent-cost-report/scripts/acr/mistakes.py +321 -0
- package/plugin/skills/agent-cost-report/scripts/acr/patterns.py +394 -0
- package/plugin/skills/agent-cost-report/scripts/acr/period.py +101 -0
- package/plugin/skills/agent-cost-report/scripts/acr/prices.py +102 -0
- package/plugin/skills/agent-cost-report/scripts/acr/render.py +516 -0
- package/plugin/skills/agent-cost-report/scripts/acr/rollup.py +364 -0
- package/plugin/skills/agent-cost-report/scripts/acr/rules.json +26 -0
- package/plugin/skills/agent-cost-report/scripts/acr/rules.py +78 -0
- package/plugin/skills/agent-cost-report/scripts/acr/sample.py +45 -0
- package/plugin/skills/agent-cost-report/scripts/acr/snapshot.py +55 -0
- package/plugin/skills/agent-cost-report/scripts/acr/sync.py +78 -0
- package/plugin/skills/agent-cost-report/scripts/acr/transcripts.py +130 -0
- package/plugin/skills/agent-cost-report/scripts/acr/wins.py +286 -0
- package/plugin/skills/agent-cost-report/scripts/acr.py +206 -0
- package/plugin/skills/agent-cost-report/scripts/tests/_db.py +49 -0
- package/plugin/skills/agent-cost-report/scripts/tests/_paths.py +9 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/codex/sessions/2026/09/18/rollout-codex-1.jsonl +4 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/make_report_fixtures.py +46 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/prices_sample.json +6 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/projects/-workspace-proj-a/sess-aaaa.jsonl +8 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/projects/-workspace-proj-b/sess-bbbb.jsonl +1 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-1day.json +823 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-30day.json +2213 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-7day.json +1252 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-empty.json +536 -0
- package/plugin/skills/agent-cost-report/scripts/tests/fixtures/report-session.json +662 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_behavior.py +275 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_devices.py +99 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_labels.py +70 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_measure.py +118 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_period.py +91 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_prices.py +126 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_render.py +114 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_rollup.py +264 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_snapshot.py +64 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_sync.py +52 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_transcripts.py +154 -0
- package/plugin/skills/agent-cost-report/scripts/tests/test_wins.py +181 -0
- package/plugin/skills/agent-cost-report/verification/precision-2026-09-26.json +7 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Phase 2B shared machinery (plan 2B.1, 2B.2, 2B.9): a second streaming pass over the same
|
|
2
|
+
transcript files and window as the Phase 1 collector, building ordered turns per session, tagging
|
|
3
|
+
every user turn human | bot | unknown (prerequisite R2, plan 0.5), finding frustration episodes,
|
|
4
|
+
and pricing wasted turns with the Phase 1 rate() table in integer micro-dollars.
|
|
5
|
+
|
|
6
|
+
Sources: box Claude Code transcripts (agent turns plus tagged user turns) and claude-mem
|
|
7
|
+
`user_prompts` rows (local and replica, tagged). Grok Bot chats stay "unavailable" (R1).
|
|
8
|
+
Field names were verified on box transcripts 2026-09-25 (plan 2B.7): assistant lines carry
|
|
9
|
+
`message.content[] {type: text|tool_use {id, name, input}}`, user lines carry
|
|
10
|
+
`{type: tool_result {tool_use_id, is_error, content}}` or text; `entrypoint` is cli | sdk-cli | sdk-ts.
|
|
11
|
+
"""
|
|
12
|
+
import collections
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import glob
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from . import costs, prices, transcripts
|
|
21
|
+
from .period import PT
|
|
22
|
+
|
|
23
|
+
# ---- constants (plan 2B.1) ----
|
|
24
|
+
GAP_MIN = 45 # episode grouping gap (MI:8)
|
|
25
|
+
WASTED_CAP_MS = 6 * 3600_000 # wasted window cap (MI:9)
|
|
26
|
+
REDO_MS = 60 * 60_000 # redo window after the episode's last message (MI:9)
|
|
27
|
+
ALEX_WAIT_CAP_MIN = 60 # Alex-minutes wait cap (MI:10)
|
|
28
|
+
EXCERPT_CHARS = 160
|
|
29
|
+
TEXT_KEEP = 6000 # chars of assistant text kept per turn (bounded memory)
|
|
30
|
+
RESULT_KEEP = 2048 # first 2 KB of a tool result (S1 rule)
|
|
31
|
+
INPUT_KEEP = 1500
|
|
32
|
+
HEADLESS = ("sdk-cli", "sdk-ts")
|
|
33
|
+
|
|
34
|
+
# Frustration Arc markers (MI:7) plus the agent markers in plan 2B.1 and the relay/automation
|
|
35
|
+
# prefixes seen in claude-mem user_prompts on this box (2026-09-26 probe).
|
|
36
|
+
BOT_MARKERS = re.compile(
|
|
37
|
+
r"^\s*(STEER from Alex|HOUSE LOCK|\[SAND_HIDDEN_PROMPT\]|<system-reminder>|<timestamp>|<cross-session-message|<command-|<local-command|"
|
|
38
|
+
r"\[Request interrupted|MAKE-PLAN\b|make-plan ONLY|# make-plan|Branch: [\w/.-]+ \(this worktree|"
|
|
39
|
+
r"\[Cross-session idle notice\]|\[Group chat:|\[Artifact comment|Agent sweep|AJ email check|Quiet overnight|"
|
|
40
|
+
r"You name new code workspaces|You are\b|/do\b|/make-plan\b|/loop\b|/claude-mem:|/ccs-|/[a-z][\w-]*:[a-z][\w-]*\b|"
|
|
41
|
+
r"(STEER|RESET|CORRECTION) from Alex|Resuming after|\[SYSTEM NOTIFICATION|<task-notification>|<pasted_content)"
|
|
42
|
+
r"|^.{0,200}\bAlex (asked|said|wants|greened|approved)\b" # third-person relays of Alex are agent-written
|
|
43
|
+
r"|^(?=.{400,})[^\n]{0,300}\bYou are (a|an|the|in|one|running|working)\b", re.I | re.S) # long briefs that address the agent
|
|
44
|
+
CORRECTION = re.compile(r"^\s*(STEER|RESET|CORRECTION) from Alex|\bSTEER\b.{0,10}\bfrom Alex\b|^\s*(RESET|CORRECTION):", re.I)
|
|
45
|
+
PROFANITY = re.compile(r"\b(fuck\w*|shit\w*|wtf|goddamn)\b", re.I)
|
|
46
|
+
FRUSTRATION_PHRASE = re.compile(r"\b(i told you|i said|why did you|who (said|made|told)|stop|never|again)\b", re.I)
|
|
47
|
+
SECRET_RE = re.compile(r"(sk-or-[\w-]+|sk-[\w-]{8,}|Bearer\s+\S+|ghp_\w+|gho_\w+|xox[abp]-[\w-]+|AKIA\w{12,}|[A-Za-z0-9+/=_-]{32,})")
|
|
48
|
+
# key=value / key: value credentials of any length (password=hunter2, token: abc, api_key="x"); the key stays, the value goes
|
|
49
|
+
KV_SECRET_RE = re.compile(r"\b(pass(?:word|wd|phrase|code)?|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|auth|credentials?)\b(\s*[:=]\s*)[\"']?[^\s\"',;]+[\"']?", re.I)
|
|
50
|
+
FENCE_RE = re.compile(r"```.*?```", re.S)
|
|
51
|
+
QUOTE_RE = re.compile(r"^\s*>.*$", re.M)
|
|
52
|
+
MISS_RE = re.compile(r"usage limit|rate.?limit|\b429\b|\b401\b|dead token|token (has )?expired|unauthori[sz]ed|quota", re.I)
|
|
53
|
+
|
|
54
|
+
FIELDS = ("input", "output", "cache_write_5m", "cache_write_1h", "cache_read")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def scrub(text, n=EXCERPT_CHARS):
|
|
58
|
+
"""Excerpts: at most 160 chars after the secret scrubber (plan 2B.1)."""
|
|
59
|
+
t = KV_SECRET_RE.sub(lambda m: f"{m.group(1)}{m.group(2)}[redacted]", (text or "").replace("\n", " ").strip())
|
|
60
|
+
t = SECRET_RE.sub("[redacted]", t)
|
|
61
|
+
return t[:n]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def user_facing(text):
|
|
65
|
+
"""'User-facing text' = assistant text with code fences, block quotes and tool inputs removed."""
|
|
66
|
+
return QUOTE_RE.sub("", FENCE_RE.sub("", text or "")).strip()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def words(text):
|
|
70
|
+
return len(re.findall(r"[A-Za-z0-9'’]+", text or ""))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_frustrated(text):
|
|
74
|
+
"""Heuristic frustration detector (MI:6, ~51% precision): profanity, ≥40% caps over ≥12 letters,
|
|
75
|
+
!!! or ???, or a phrase like 'i told you'."""
|
|
76
|
+
if not text: return False
|
|
77
|
+
if PROFANITY.search(text) or "!!!" in text or "???" in text or FRUSTRATION_PHRASE.search(text): return True
|
|
78
|
+
letters = [c for c in text if c.isalpha()]
|
|
79
|
+
return len(letters) >= 12 and sum(c.isupper() for c in letters) / len(letters) >= 0.4
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ts_ms(iso):
|
|
83
|
+
t = transcripts.ts(iso)
|
|
84
|
+
return int(t.timestamp() * 1000)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _usage_row(u):
|
|
88
|
+
"""copied from transcripts.py:66-75 (the same 5m/1h split as Phase 1)."""
|
|
89
|
+
cc = u.get("cache_creation") or {}
|
|
90
|
+
cw = u.get("cache_creation_input_tokens", 0) or 0
|
|
91
|
+
cw1 = cc.get("ephemeral_1h_input_tokens", 0) or 0
|
|
92
|
+
cw5 = cc.get("ephemeral_5m_input_tokens")
|
|
93
|
+
return dict(input=u.get("input_tokens", 0) or 0, output=u.get("output_tokens", 0) or 0,
|
|
94
|
+
cache_write_5m=cw5 if cw5 is not None else cw - cw1, cache_write_1h=cw1,
|
|
95
|
+
cache_read=u.get("cache_read_input_tokens", 0) or 0)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _blocks(content):
|
|
99
|
+
if isinstance(content, str): return [dict(type="text", text=content)]
|
|
100
|
+
return [b for b in (content or []) if isinstance(b, dict)]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _result_text(content):
|
|
104
|
+
if isinstance(content, str): return content
|
|
105
|
+
out = []
|
|
106
|
+
for b in content or []:
|
|
107
|
+
if isinstance(b, dict) and b.get("type") == "text": out.append(b.get("text") or "")
|
|
108
|
+
elif isinstance(b, str): out.append(b)
|
|
109
|
+
return "\n".join(out)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def new_session():
|
|
113
|
+
return dict(entrypoint=None, turns={}, users=[], files=set(), sidechain_prompts=0)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def scan(s_ms, e_ms, session=None, pattern=transcripts.CLAUDE_GLOB):
|
|
117
|
+
"""Stream the window's Claude Code transcripts (same glob and mtime prefilter as Phase 1).
|
|
118
|
+
Returns {sessionId: {entrypoint, turns: {key: turn}, users: [user event], files}}.
|
|
119
|
+
A turn = one assistant API reply keyed (message.id, requestId) with its text, tool_uses and usage."""
|
|
120
|
+
s_dt = dt.datetime.fromtimestamp(s_ms / 1000, PT) if s_ms is not None else None
|
|
121
|
+
files = [f for f in glob.glob(os.path.expanduser(pattern), recursive=True)
|
|
122
|
+
if s_dt is None or dt.datetime.fromtimestamp(os.path.getmtime(f), PT) >= s_dt]
|
|
123
|
+
sessions = collections.defaultdict(new_session)
|
|
124
|
+
for f in files:
|
|
125
|
+
with open(f, errors="ignore") as fh:
|
|
126
|
+
for line in fh:
|
|
127
|
+
if '"timestamp"' not in line or ('"assistant"' not in line and '"user"' not in line): continue
|
|
128
|
+
if session is not None and session not in line: continue
|
|
129
|
+
try: d = json.loads(line)
|
|
130
|
+
except Exception: continue
|
|
131
|
+
typ = d.get("type")
|
|
132
|
+
if typ not in ("assistant", "user") or not d.get("timestamp"): continue
|
|
133
|
+
sid = d.get("sessionId") or d.get("session_id")
|
|
134
|
+
if session is not None and sid != session: continue
|
|
135
|
+
try: t_ms = _ts_ms(d["timestamp"])
|
|
136
|
+
except Exception: continue
|
|
137
|
+
if s_ms is not None and not (s_ms <= t_ms < e_ms): continue
|
|
138
|
+
S = sessions[sid]; S["files"].add(f)
|
|
139
|
+
if d.get("entrypoint") and not S["entrypoint"]: S["entrypoint"] = d["entrypoint"]
|
|
140
|
+
m = d.get("message") or {}
|
|
141
|
+
if typ == "assistant":
|
|
142
|
+
key = (m.get("id"), d.get("requestId"))
|
|
143
|
+
T = S["turns"].get(key)
|
|
144
|
+
if T is None:
|
|
145
|
+
u = m.get("usage") or {}
|
|
146
|
+
row = _usage_row(u) if u else None
|
|
147
|
+
T = S["turns"][key] = dict(key=key, ts_ms=t_ms, model=m.get("model"), text="", tool_uses=[], usage=row,
|
|
148
|
+
sidechain=bool(d.get("isSidechain")), file=f, uuid=d.get("uuid"))
|
|
149
|
+
elif T["usage"] is None and m.get("usage"): T["usage"] = _usage_row(m["usage"])
|
|
150
|
+
for b in _blocks(m.get("content")):
|
|
151
|
+
if b.get("type") == "text" and len(T["text"]) < TEXT_KEEP: T["text"] += (b.get("text") or "")[:TEXT_KEEP]
|
|
152
|
+
elif b.get("type") == "tool_use":
|
|
153
|
+
inp = b.get("input"); inp_s = inp if isinstance(inp, str) else json.dumps(inp, sort_keys=True)
|
|
154
|
+
T["tool_uses"].append(dict(id=b.get("id"), name=b.get("name") or "", input=inp if isinstance(inp, dict) else {}, input_s=inp_s[:INPUT_KEEP]))
|
|
155
|
+
else:
|
|
156
|
+
blocks = _blocks(m.get("content")); results = []; texts = []
|
|
157
|
+
for b in blocks:
|
|
158
|
+
if b.get("type") == "tool_result":
|
|
159
|
+
results.append(dict(tool_use_id=b.get("tool_use_id"), is_error=bool(b.get("is_error")), content=_result_text(b.get("content"))[:RESULT_KEEP]))
|
|
160
|
+
elif b.get("type") == "text": texts.append(b.get("text") or "")
|
|
161
|
+
text = "\n".join(texts).strip()
|
|
162
|
+
if not results and not text: continue
|
|
163
|
+
if d.get("isMeta") and not results: continue # harness meta lines are not prompts
|
|
164
|
+
S["users"].append(dict(ts_ms=t_ms, text=text[:TEXT_KEEP], results=results, sidechain=bool(d.get("isSidechain")),
|
|
165
|
+
uuid=d.get("uuid"), turn_origin=d.get("turnOrigin")))
|
|
166
|
+
return dict(sessions), dict(files_seen=len(files))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---- human-or-bot tagging (R2) ----
|
|
170
|
+
def session_tag(S):
|
|
171
|
+
"""alex_direct (interactive cli, no agent-prompt markers) | agent_relayed | unknown (plan 2B.1)."""
|
|
172
|
+
prompts = [u for u in S["users"] if u["text"] and not u["sidechain"] and not u["results"]]
|
|
173
|
+
if S["entrypoint"] in HEADLESS: return "agent_relayed"
|
|
174
|
+
marked = [bool(BOT_MARKERS.search(u["text"])) for u in prompts]
|
|
175
|
+
if marked and (marked[0] or all(marked)): return "agent_relayed" # launched by an agent, or nothing but relays
|
|
176
|
+
if S["entrypoint"] == "cli" and prompts: return "alex_direct" # a marker later on tags that prompt alone (tag_user_turns)
|
|
177
|
+
return "unknown"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def tag_user_turns(S):
|
|
181
|
+
tag = session_tag(S); S["tag"] = tag
|
|
182
|
+
for u in S["users"]:
|
|
183
|
+
if u["results"] and not u["text"]: u["author"] = "tool"; continue
|
|
184
|
+
if u["sidechain"] or tag == "agent_relayed" or BOT_MARKERS.search(u["text"]) or u.get("turn_origin") in ("task_notification", "sdk", "scheduled", "peer"):
|
|
185
|
+
u["author"] = "bot"
|
|
186
|
+
elif tag == "alex_direct": u["author"] = "human"
|
|
187
|
+
else: u["author"] = "unknown"
|
|
188
|
+
return tag
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def tag_prompt_row(text):
|
|
192
|
+
"""A claude-mem user_prompts row is human only when it passes the marker filter."""
|
|
193
|
+
return "bot" if BOT_MARKERS.search(text or "") else "human"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---- pricing turns ----
|
|
197
|
+
def price_turns(sessions, pricer):
|
|
198
|
+
for S in sessions.values():
|
|
199
|
+
for T in S["turns"].values():
|
|
200
|
+
rt = pricer.rate(T["model"]) if T["model"] else None
|
|
201
|
+
T["x1e6"] = costs.api_equiv_x1e6(T["usage"], rt) if (T["usage"] and rt) else None
|
|
202
|
+
T["model_status"] = "observed" if T["model"] else "assumed"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def ordered_turns(S):
|
|
206
|
+
return sorted(S["turns"].values(), key=lambda T: T["ts_ms"])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def turn_cost(turns):
|
|
210
|
+
"""Sum of priced turns in micro-dollars; unpriced turns add nothing but are counted."""
|
|
211
|
+
return sum(T["x1e6"] or 0 for T in turns)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---- human messages: transcripts first, claude-mem user_prompts only for sessions without a box transcript ----
|
|
215
|
+
def load_prompt_rows(db, scope):
|
|
216
|
+
cols = "content_session_id, prompt_text, created_at_epoch, coalesce(origin_device_id,'local')"
|
|
217
|
+
if scope.kind == "session":
|
|
218
|
+
q = f"SELECT {cols} FROM user_prompts WHERE content_session_id = ?"; args = (scope.session,)
|
|
219
|
+
else:
|
|
220
|
+
q = f"SELECT {cols} FROM user_prompts WHERE created_at_epoch >= ? AND created_at_epoch < ?"; args = (scope.S, scope.E)
|
|
221
|
+
return [dict(session=r[0], text=r[1] or "", ts_ms=r[2], device=r[3]) for r in db.execute(q, args)]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def human_messages(sessions, prompt_rows):
|
|
225
|
+
"""Per content_session_id: [{ts_ms, text, author, source}] ordered. Sessions with a box transcript
|
|
226
|
+
use its tagged user turns; other sessions (Mac replica, remote) use the tagged user_prompts rows."""
|
|
227
|
+
msgs = collections.defaultdict(list); tags = collections.Counter()
|
|
228
|
+
for sid, S in sessions.items():
|
|
229
|
+
for u in S["users"]:
|
|
230
|
+
if u["author"] == "tool": continue
|
|
231
|
+
tags[u["author"]] += 1
|
|
232
|
+
msgs[sid].append(dict(ts_ms=u["ts_ms"], text=u["text"], author=u["author"], source="transcript"))
|
|
233
|
+
for r in prompt_rows:
|
|
234
|
+
if r["session"] in sessions: continue
|
|
235
|
+
a = tag_prompt_row(r["text"]); tags[a] += 1
|
|
236
|
+
msgs[r["session"]].append(dict(ts_ms=r["ts_ms"], text=r["text"], author=a, source="user_prompts", device=r["device"]))
|
|
237
|
+
for v in msgs.values(): v.sort(key=lambda m: m["ts_ms"])
|
|
238
|
+
return msgs, dict(human=tags["human"], bot=tags["bot"], unknown=tags["unknown"])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---- episodes (plan 2B.1, 2B.2) ----
|
|
242
|
+
def episode_id(session, first_ts):
|
|
243
|
+
return "EP-" + hashlib.sha1(f"{session}|{first_ts}".encode()).hexdigest()[:10]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def find_episodes(msgs, phrase_hit=None):
|
|
247
|
+
"""Frustrated human-tagged messages in one session with gaps ≤45 min form one episode. A human message
|
|
248
|
+
that matches a pattern's own human phrase (2B.3) is a candidate too; the pattern detector is its confirmation."""
|
|
249
|
+
eps = []
|
|
250
|
+
for sid, ms in msgs.items():
|
|
251
|
+
cur = None
|
|
252
|
+
for m in ms:
|
|
253
|
+
if m["author"] != "human" or not (is_frustrated(m["text"]) or (phrase_hit and phrase_hit(m["text"]))): continue
|
|
254
|
+
if cur and m["ts_ms"] - cur["messages"][-1]["ts_ms"] <= GAP_MIN * 60_000: cur["messages"].append(m)
|
|
255
|
+
else:
|
|
256
|
+
cur = dict(session=sid, messages=[m]); eps.append(cur)
|
|
257
|
+
for e in eps:
|
|
258
|
+
e["episode_id"] = episode_id(e["session"], e["messages"][0]["ts_ms"])
|
|
259
|
+
e["first_ts"] = e["messages"][0]["ts_ms"]; e["last_ts"] = e["messages"][-1]["ts_ms"]
|
|
260
|
+
return eps
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def episode_windows(ep, S, all_msgs):
|
|
264
|
+
"""wasted = turns from the previous human prompt to the first frustrated message (cap 6h);
|
|
265
|
+
redo = turns in the 60 min after the last message. Same session only (MI:9)."""
|
|
266
|
+
if S is None: return [], [], None
|
|
267
|
+
prev = [m["ts_ms"] for m in all_msgs.get(ep["session"], []) if m["ts_ms"] < ep["first_ts"] and m["author"] in ("human", "bot", "unknown")]
|
|
268
|
+
start = max(prev) if prev else ep["first_ts"] - WASTED_CAP_MS
|
|
269
|
+
start = max(start, ep["first_ts"] - WASTED_CAP_MS)
|
|
270
|
+
turns = ordered_turns(S)
|
|
271
|
+
wasted = [T for T in turns if start <= T["ts_ms"] < ep["first_ts"]]
|
|
272
|
+
redo = [T for T in turns if ep["last_ts"] < T["ts_ms"] <= ep["last_ts"] + REDO_MS]
|
|
273
|
+
before = [T["ts_ms"] for T in turns if T["ts_ms"] < ep["first_ts"]]
|
|
274
|
+
last_agent = max(before) if before else None
|
|
275
|
+
return wasted, redo, last_agent
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def alex_minutes(ep, last_agent_ts):
|
|
279
|
+
"""time since the last agent output before the complaint (cap 60) + episode span + 1 min per message (MI:10)."""
|
|
280
|
+
wait = min(ALEX_WAIT_CAP_MIN, (ep["first_ts"] - last_agent_ts) / 60_000) if last_agent_ts else 0
|
|
281
|
+
span = (ep["last_ts"] - ep["first_ts"]) / 60_000
|
|
282
|
+
return round(wait + span + len(ep["messages"]), 1)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def pt_iso(ms):
|
|
286
|
+
return dt.datetime.fromtimestamp(ms / 1000, PT).isoformat(timespec="minutes")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def day_pt(ms):
|
|
290
|
+
return dt.datetime.fromtimestamp(ms / 1000, PT).strftime("%Y-%m-%d")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---- failure-signal turn spans (plan 2B.9) ----
|
|
294
|
+
def failure_spans(S, msgs):
|
|
295
|
+
"""Rework / Wrong turn = turns from the user message before the corrected work up to the
|
|
296
|
+
steer/reset/correction message; Recovery after miss = turns from the miss to the first successful
|
|
297
|
+
tool result after it; Looping is handled by S1. Returns {failure_type: set(keys)}."""
|
|
298
|
+
turns = ordered_turns(S); out = collections.defaultdict(set)
|
|
299
|
+
users = [u for u in S["users"] if u["text"] and not u["results"]]
|
|
300
|
+
for i, u in enumerate(users):
|
|
301
|
+
if CORRECTION.search(u["text"]):
|
|
302
|
+
prev = users[i - 1]["ts_ms"] if i else (u["ts_ms"] - WASTED_CAP_MS)
|
|
303
|
+
for T in turns:
|
|
304
|
+
if prev <= T["ts_ms"] < u["ts_ms"]: out["Rework"].add(T["key"])
|
|
305
|
+
miss_at = None
|
|
306
|
+
for u in S["users"]:
|
|
307
|
+
for r in u["results"]:
|
|
308
|
+
if miss_at is None and (r["is_error"] and MISS_RE.search(r["content"] or "")): miss_at = u["ts_ms"]
|
|
309
|
+
elif miss_at is not None and not r["is_error"]:
|
|
310
|
+
for T in turns:
|
|
311
|
+
if miss_at <= T["ts_ms"] <= u["ts_ms"]: out["Recovery after miss"].add(T["key"])
|
|
312
|
+
miss_at = None
|
|
313
|
+
return out
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Optional cheap classifier pass (plan 2B.4; G5 and G8 settled): OFF by default, enabled per run with
|
|
2
|
+
--classify, hard cap $2.00 per run, regular inference OPENROUTER_API_KEY only (never a management key,
|
|
3
|
+
never read from a settings file). It only sees candidates the heuristics could not settle. Its own
|
|
4
|
+
spend is reported separately ("classifier cost (separate)") and never added to agent cost.
|
|
5
|
+
|
|
6
|
+
Run path: OpenRouter chat completions (OpenAI-compatible, https://openrouter.ai/docs/api-reference/chat-completion),
|
|
7
|
+
urllib only. Input per candidate: the flagged text plus one message before and after, scrubbed, cut to
|
|
8
|
+
1,500 characters. Output JSON {label: yes|no|unsure, pattern, reason (≤20 words)}.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
from . import behavior, costs
|
|
16
|
+
|
|
17
|
+
DEFAULT_MODEL = "anthropic/claude-haiku-4.5"
|
|
18
|
+
CAP_USD = 2.00
|
|
19
|
+
URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
20
|
+
CANDIDATE_CHARS = 1500
|
|
21
|
+
EST_IN_TOKENS = 1600 # plan 2B.4 sizing
|
|
22
|
+
EST_OUT_TOKENS = 60
|
|
23
|
+
QUESTIONS = {
|
|
24
|
+
"frustration": "Is this human message a genuine complaint about the agent's work? Answer yes or no.",
|
|
25
|
+
"P1_invented_gates": "Was this a real gate (something only Alex can do: cold Allow click, payment method, wet-ink signature, phone 2FA) or an invented one? yes = invented.",
|
|
26
|
+
"P4_over_engineering": "Was the extra scope asked for? yes = NOT asked for (over-engineering).",
|
|
27
|
+
"P5_not_asked": "Do the actions match the last human instruction? yes = they do NOT match.",
|
|
28
|
+
"P6_fake_output": "Is each number in this text sourced from a tool result shown? yes = unsourced.",
|
|
29
|
+
"P12_unclear": "Which pattern (P1..P11) does this complaint belong to, if any?",
|
|
30
|
+
"S2_hedging": "Was the answer already available to the agent when it hedged? yes = available (hedge counts).",
|
|
31
|
+
"praise": "Is this praise sincere (yes) or sarcastic (no)?",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ClassifierError(RuntimeError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def estimate_usd(n, rate):
|
|
40
|
+
return costs.usd(int(n * (EST_IN_TOKENS * rate["input_usd_per_mtok"] + EST_OUT_TOKENS * rate["output_usd_per_mtok"])))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _call(model, prompt, key, url=URL, timeout=60):
|
|
44
|
+
body = json.dumps(dict(model=model, max_tokens=120, temperature=0,
|
|
45
|
+
messages=[dict(role="system", content="Reply with one JSON object: {\"label\": \"yes|no|unsure\", \"pattern\": \"...\", \"reason\": \"<=20 words\"}"),
|
|
46
|
+
dict(role="user", content=prompt)])).encode()
|
|
47
|
+
req = urllib.request.Request(url, data=body, headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
|
|
48
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: d = json.load(resp)
|
|
49
|
+
u = d.get("usage") or {}
|
|
50
|
+
txt = ((d.get("choices") or [{}])[0].get("message") or {}).get("content") or "{}"
|
|
51
|
+
try: out = json.loads(txt[txt.find("{"): txt.rfind("}") + 1])
|
|
52
|
+
except Exception: out = dict(label="unsure", pattern=None, reason="unparseable")
|
|
53
|
+
return out, int(u.get("prompt_tokens") or 0), int(u.get("completion_tokens") or 0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run(candidates, rate, model=DEFAULT_MODEL, cap_usd=CAP_USD, key=None, call=_call, seed=0):
|
|
57
|
+
"""candidates: [{id, kind, text, before, after}]. Returns (labels {id: {...}}, summary).
|
|
58
|
+
Prints the estimate first; over the cap it classifies a stratified random sample and scales up."""
|
|
59
|
+
key = key if key is not None else os.environ.get("OPENROUTER_API_KEY")
|
|
60
|
+
if not key: raise ClassifierError("OPENROUTER_API_KEY is not set (regular inference key, G5); classifier not run")
|
|
61
|
+
if not rate: raise ClassifierError(f"no list price for {model}; classifier not run")
|
|
62
|
+
est = estimate_usd(len(candidates), rate); sampled = False; chosen = list(candidates)
|
|
63
|
+
print(f"classifier: {len(candidates)} candidates, estimated ${est:.2f} at list, cap ${cap_usd:.2f}")
|
|
64
|
+
if est > cap_usd:
|
|
65
|
+
per = estimate_usd(1, rate) or 0.001; n = max(1, int(cap_usd / per * 0.9))
|
|
66
|
+
rnd = random.Random(seed); by_kind = {}
|
|
67
|
+
for c in candidates: by_kind.setdefault(c["kind"], []).append(c)
|
|
68
|
+
chosen = []
|
|
69
|
+
for kind, cs in by_kind.items():
|
|
70
|
+
k = max(1, round(n * len(cs) / len(candidates))); chosen += rnd.sample(cs, min(k, len(cs)))
|
|
71
|
+
sampled = True
|
|
72
|
+
labels = {}; spent = 0; in_tok = out_tok = 0; last = 0; stopped = False
|
|
73
|
+
cap_x1e6 = int(round(cap_usd * 1e6)); per_x1e6 = int(EST_IN_TOKENS * rate["input_usd_per_mtok"] + EST_OUT_TOKENS * rate["output_usd_per_mtok"])
|
|
74
|
+
for c in chosen:
|
|
75
|
+
if spent + max(per_x1e6, last) > cap_x1e6: stopped = True; break # hard stop: never send a request the cap cannot pay for
|
|
76
|
+
prompt = f"{QUESTIONS.get(c['kind'], QUESTIONS['P12_unclear'])}\n\nBEFORE: {behavior.scrub(c.get('before',''), 400)}\nFLAGGED: {behavior.scrub(c['text'], CANDIDATE_CHARS)}\nAFTER: {behavior.scrub(c.get('after',''), 400)}"
|
|
77
|
+
out, i, o = call(model, prompt, key); in_tok += i; out_tok += o
|
|
78
|
+
last = int(i * rate["input_usd_per_mtok"] + o * rate["output_usd_per_mtok"]); spent += last
|
|
79
|
+
labels[c["id"]] = dict(label=out.get("label", "unsure"), pattern=out.get("pattern"), reason=str(out.get("reason", ""))[:120], label_source="classifier")
|
|
80
|
+
scale = (len(candidates) / len(labels)) if (sampled and labels) else 1.0
|
|
81
|
+
return labels, dict(ran=True, model=model, spend_usd=costs.usd(spent), cap_usd=cap_usd, sampled_n=len(labels) if sampled else 0,
|
|
82
|
+
candidates_n=len(candidates), estimated_usd=est, scale=round(scale, 2), input_tokens=in_tok, output_tokens=out_tok, stopped_at_cap=stopped,
|
|
83
|
+
note="estimated from a sample of %d" % len(labels) if sampled else None)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Agent cost from measured tokens, observer cost kept apart, extrapolation (plan Phase 2.2).
|
|
2
|
+
|
|
3
|
+
api_equiv() copied from /workspace/weekly-cost-workflow/weekly_report.py:91-105 with the Phase 1
|
|
4
|
+
rate() (1h cache writes at the listed cache_write_1h, else 2 x input). Money is accumulated in
|
|
5
|
+
integer micro-dollars (tokens x USD/MTok is exactly micro-dollars). The observer (note-taker) cost
|
|
6
|
+
is priced at the input rate only (:70-73) and lives only in spend.observer_note_taker_est_usd.
|
|
7
|
+
Extrapolation copied from :239-246. Unpriced = rate() None, output price None, or any negative price
|
|
8
|
+
(OpenRouter lists -1 for a few variable routers): such rows are never multiplied.
|
|
9
|
+
"""
|
|
10
|
+
import collections
|
|
11
|
+
|
|
12
|
+
from . import prices as prices_mod
|
|
13
|
+
|
|
14
|
+
TOKEN_FIELDS = ("input", "output", "cache_write_5m", "cache_write_1h", "cache_read")
|
|
15
|
+
PRICE_FIELDS = ("input_usd_per_mtok", "output_usd_per_mtok", "cache_read", "cache_write", "cache_write_1h")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def usable_rate(rt):
|
|
19
|
+
"""A rate is usable only when every price is present and non-negative."""
|
|
20
|
+
if not rt: return False
|
|
21
|
+
return all(rt.get(f) is not None and rt[f] >= 0 for f in PRICE_FIELDS)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def api_equiv_x1e6(r, rt):
|
|
25
|
+
"""copied from weekly_report.py:91-105 (integer micro-dollars); None when unpriced."""
|
|
26
|
+
if not usable_rate(rt): return None
|
|
27
|
+
i = rt["input_usd_per_mtok"]
|
|
28
|
+
return int(round(r["input"] * i + r["output"] * rt["output_usd_per_mtok"] + r["cache_write_5m"] * rt["cache_write"]
|
|
29
|
+
+ r["cache_write_1h"] * rt["cache_write_1h"] + r["cache_read"] * rt["cache_read"]))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def price_block(rt):
|
|
33
|
+
"""model_prices_usd_per_mtok for a line item / by_model row."""
|
|
34
|
+
if not rt: return None
|
|
35
|
+
return dict(input=rt["input_usd_per_mtok"], output=rt["output_usd_per_mtok"], cache_read=rt["cache_read"],
|
|
36
|
+
cache_write=rt["cache_write"], cache_write_1h=rt["cache_write_1h"], key=rt["key"], price_source=rt["price_source"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Pricer:
|
|
40
|
+
"""rate() with a per-model cache and the unpriced register."""
|
|
41
|
+
def __init__(self, prices):
|
|
42
|
+
self.models = prices["models"]; self._cache = {}
|
|
43
|
+
self.unpriced = collections.defaultdict(lambda: collections.defaultdict(collections.Counter)) # model -> role -> {calls, tokens}
|
|
44
|
+
|
|
45
|
+
def rate(self, model):
|
|
46
|
+
if model not in self._cache: self._cache[model] = prices_mod.rate(model, self.models)
|
|
47
|
+
return self._cache[model]
|
|
48
|
+
|
|
49
|
+
def usable(self, model):
|
|
50
|
+
return usable_rate(self.rate(model))
|
|
51
|
+
|
|
52
|
+
def reason(self, model):
|
|
53
|
+
rt = self.rate(model)
|
|
54
|
+
if rt is None: return "not in price list"
|
|
55
|
+
if rt.get("output_usd_per_mtok") is None: return "no output price listed"
|
|
56
|
+
return "negative (variable) price listed"
|
|
57
|
+
|
|
58
|
+
def observer_input_rate(self, model):
|
|
59
|
+
"""USD/MTok input rate for the note-taker model, or None (then the tokens are registered as unpriced)."""
|
|
60
|
+
rt = self.rate(model)
|
|
61
|
+
if rt is None or rt["input_usd_per_mtok"] is None or rt["input_usd_per_mtok"] < 0: return None
|
|
62
|
+
return rt["input_usd_per_mtok"]
|
|
63
|
+
|
|
64
|
+
def unpriced_list(self):
|
|
65
|
+
return [dict(model=m, role=role, calls=c["calls"], tokens=c["tokens"], reason=self.reason(m))
|
|
66
|
+
for m, roles in sorted(self.unpriced.items()) for role, c in sorted(roles.items())]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _zero():
|
|
70
|
+
c = collections.Counter(); c.update({f: 0 for f in TOKEN_FIELDS}); return c
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def price_rows(rows, pricer):
|
|
74
|
+
"""Accumulate usage rows per transcript session and per (src, model) (weekly_report.py:98-105).
|
|
75
|
+
Returns (by_session, by_model, stamps_by_session). Unpriced rows count tokens and calls but no dollars."""
|
|
76
|
+
by_session = collections.defaultdict(_zero); by_model = collections.defaultdict(_zero); stamps = collections.defaultdict(list)
|
|
77
|
+
for r in rows:
|
|
78
|
+
rt = pricer.rate(r["model"]); c = api_equiv_x1e6(r, rt)
|
|
79
|
+
for tgt in (by_session[r["session"]], by_model[(r["src"], r["model"])]):
|
|
80
|
+
for f in TOKEN_FIELDS: tgt[f] += r[f]
|
|
81
|
+
tgt["calls"] += 1
|
|
82
|
+
if c is None: tgt["unpriced_calls"] += 1
|
|
83
|
+
else: tgt["usd_x1e6"] += c
|
|
84
|
+
if c is None:
|
|
85
|
+
u = pricer.unpriced[r["model"] or "<none>"]["agent"]; u["calls"] += 1; u["tokens"] += sum(r[f] for f in TOKEN_FIELDS)
|
|
86
|
+
stamps[r["session"]].append(r["ts"])
|
|
87
|
+
# per-session dominant model (by output tokens) for the line item's `model` field
|
|
88
|
+
by_session[r["session"]]["model:" + (r["model"] or "")] += r["output"]
|
|
89
|
+
return by_session, by_model, stamps
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def dominant_model(counter):
|
|
93
|
+
ms = [(v, k[6:]) for k, v in counter.items() if k.startswith("model:")]
|
|
94
|
+
return max(ms)[1] if ms else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def usd(x1e6):
|
|
98
|
+
return round(x1e6 / 1e6, 2)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def extrapolation(items):
|
|
102
|
+
"""copied from weekly_report.py:239-246: ratio = measured agent USD per 1M observer tokens over sessions
|
|
103
|
+
with a transcript, applied to the observer tokens of sessions with none. Only those sessions are touched.
|
|
104
|
+
items: line-item dicts with has_transcript, observer_tokens, cost_x1e6 (measured micro-dollars)."""
|
|
105
|
+
# transcripts with no claude-mem session (orphan=True) have no observer tokens and stay out of the ratio (weekly_report.py:239-246 basis)
|
|
106
|
+
m_obs = sum(i["observer_tokens"] for i in items if i["has_transcript"] and not i.get("orphan"))
|
|
107
|
+
m_usd = sum(i["cost_x1e6"] for i in items if i["has_transcript"] and not i.get("orphan")) / 1e6
|
|
108
|
+
u_obs = sum(i["observer_tokens"] for i in items if not i["has_transcript"])
|
|
109
|
+
ratio = m_usd / m_obs * 1e6 if m_obs else None
|
|
110
|
+
total = 0
|
|
111
|
+
for i in items:
|
|
112
|
+
if not i["has_transcript"] and ratio is not None:
|
|
113
|
+
i["extrapolated_x1e6"] = int(round(i["observer_tokens"] * ratio)); total += i["extrapolated_x1e6"]
|
|
114
|
+
else:
|
|
115
|
+
i["extrapolated_x1e6"] = 0
|
|
116
|
+
n_un = sum(1 for i in items if not i["has_transcript"])
|
|
117
|
+
if n_un == 0 and items: return 0.0, "all sessions measured (no extrapolation needed)", ratio # plan 5.4: say so, never drop the line silently
|
|
118
|
+
basis = (f"EXTRAPOLATED (low confidence): on this box, measured sessions cost ${ratio:.2f} API-equivalent per 1M observer tokens; "
|
|
119
|
+
f"applied to {u_obs:,} observer tokens from {n_un} sessions with no transcript here (remote devices). "
|
|
120
|
+
f"Assumes remote work looks like box work.") if ratio is not None else "n/a (no measured session to derive a ratio from)"
|
|
121
|
+
return (round(total / 1e6, 2) if ratio is not None else None), basis, ratio
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Phase 5: per-device transcript export and merge (plan 5.2), so Mac sessions can move from
|
|
2
|
+
"extrapolated (low confidence)" to measured tokens without any transcript leaving the machine.
|
|
3
|
+
|
|
4
|
+
Export (runs on the machine that owns the transcripts): the Phase 1 collector rows for the window plus
|
|
5
|
+
that machine's local sdk_sessions map {memory_session_id -> content_session_id, project, started_at_epoch},
|
|
6
|
+
read from a read-only snapshot of its local claude-mem.db. Only ids, timestamps, token counts and model
|
|
7
|
+
names: no prompt text, no observation text, no settings, no file paths.
|
|
8
|
+
|
|
9
|
+
Merge (runs on the box): rows join to box-side sessions on memory_session_id (fallback content_session_id);
|
|
10
|
+
joined sessions flip from cost_basis extrapolated to estimated_usage with device=<label>. Whatever stays
|
|
11
|
+
unmeasured is extrapolated again and keeps its low-confidence label. Rows that match no box session are
|
|
12
|
+
counted as unmatched, like any other transcript.
|
|
13
|
+
"""
|
|
14
|
+
import datetime as dt
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
|
|
19
|
+
from . import period, snapshot
|
|
20
|
+
|
|
21
|
+
ROW_FIELDS = ("src", "session", "sidechain", "model", "ts", "input", "output", "cache_write", "cache_write_1h", "cache_write_5m", "cache_read", "cost_usd_reported")
|
|
22
|
+
FORBIDDEN = ("text", "prompt", "content", "title", "file", "cwd", "settings")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def export(label, usage_doc, outdir, window_block, live_db=None):
|
|
26
|
+
"""Write <outdir>/device-usage-<label>.json from a collect() document and the local DB. The DB
|
|
27
|
+
snapshot is removed afterwards so the export directory holds only the small file."""
|
|
28
|
+
rows = [dict({k: r.get(k) for k in ROW_FIELDS}, device=label) for r in usage_doc["rows"]]
|
|
29
|
+
snap = snapshot.snapshot(outdir, live=live_db); db = snapshot.open_snapshot(snap)
|
|
30
|
+
try:
|
|
31
|
+
ids = sorted({r["session"] for r in rows if r["session"]})
|
|
32
|
+
q = "select memory_session_id, content_session_id, project, started_at_epoch from sdk_sessions where "
|
|
33
|
+
if window_block.get("start_epoch_ms") is not None:
|
|
34
|
+
cur = db.execute(q + "started_at_epoch >= ? and started_at_epoch < ?", (window_block["start_epoch_ms"], window_block["end_epoch_ms"]))
|
|
35
|
+
else:
|
|
36
|
+
cur = db.execute(q + f"content_session_id in ({','.join('?' * len(ids))})", ids) if ids else []
|
|
37
|
+
sessions = {r[0]: dict(content_session_id=r[1], project=r[2], started_at_epoch=r[3]) for r in cur if r[0]}
|
|
38
|
+
finally:
|
|
39
|
+
db.close()
|
|
40
|
+
try: os.remove(snap)
|
|
41
|
+
except OSError: pass
|
|
42
|
+
doc = dict(device=label, host_label=platform.node(), exported_at_pt=period.now_pt().strftime("%Y-%m-%d %H:%M PT"), window=window_block,
|
|
43
|
+
rows=rows, sessions=sessions, contents="usage rows (ids, timestamps, token counts, model names) and the local session id map; no text",
|
|
44
|
+
behavior_export=None) # per-session pattern counts / episode windows may be added later (plan 5.2, numbers only)
|
|
45
|
+
assert not any(k in r for r in rows for k in FORBIDDEN)
|
|
46
|
+
path = os.path.join(outdir, f"device-usage-{label}.json")
|
|
47
|
+
with open(path, "w") as fh: json.dump(doc, fh)
|
|
48
|
+
return path, doc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load(path):
|
|
52
|
+
with open(path) as fh: d = json.load(fh)
|
|
53
|
+
for k in ("device", "window", "rows", "sessions"):
|
|
54
|
+
if k not in d: raise ValueError(f"{path}: not a device-usage export (missing {k})")
|
|
55
|
+
bad = [k for r in d["rows"] for k in FORBIDDEN if k in r]
|
|
56
|
+
if bad: raise ValueError(f"{path}: export carries forbidden fields {sorted(set(bad))}")
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def merge(rows, exports, sess, window_block):
|
|
61
|
+
"""Append exported rows to `rows`, re-keyed to the box session's content_session_id when the export's
|
|
62
|
+
memory_session_id (or content id) is known on the box. Returns a summary per export."""
|
|
63
|
+
by_mem = {mid: v["content_session_id"] for mid, v in sess.items()}
|
|
64
|
+
by_cs = {v["content_session_id"] for v in sess.values()}
|
|
65
|
+
seen = {(r["session"], r["ts"], r["input"], r["output"], r["cache_read"]) for r in rows}
|
|
66
|
+
S, E = window_block.get("start_epoch_ms"), window_block.get("end_epoch_ms") # None for a --session run (no period)
|
|
67
|
+
out = []
|
|
68
|
+
for d in exports:
|
|
69
|
+
label = d["device"]; mem_by_cs = {v["content_session_id"]: mid for mid, v in d["sessions"].items()}
|
|
70
|
+
joined = unmatched = dup = outside = 0
|
|
71
|
+
for r in d["rows"]:
|
|
72
|
+
if S is not None and not (S <= _ts_ms(r["ts"]) < E): outside += 1; continue # a wrong-period export never inflates the headline
|
|
73
|
+
cs = r["session"]; mid = mem_by_cs.get(cs)
|
|
74
|
+
if mid in by_mem: cs2 = by_mem[mid]; joined += 1
|
|
75
|
+
elif cs in by_cs: cs2 = cs; joined += 1
|
|
76
|
+
else: cs2 = cs; unmatched += 1
|
|
77
|
+
key = (cs2, r["ts"], r["input"], r["output"], r["cache_read"])
|
|
78
|
+
if key in seen: dup += 1; continue
|
|
79
|
+
seen.add(key)
|
|
80
|
+
rows.append(dict(r, session=cs2, device=label, file=None, dir=f"device:{label}", cwd=None, export_memory_session_id=mid, export_session=cs))
|
|
81
|
+
same_window = (d["window"].get("start_pt"), d["window"].get("end_exclusive_pt")) == (window_block.get("start_pt"), window_block.get("end_exclusive_pt"))
|
|
82
|
+
out.append(dict(device=label, host_label=d.get("host_label"), exported_at_pt=d.get("exported_at_pt"), rows=len(d["rows"]), joined=joined, unmatched=unmatched, duplicates_skipped=dup,
|
|
83
|
+
outside_window=outside, window=d["window"], window_matches_report=same_window))
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ts_ms(iso):
|
|
88
|
+
return int(dt.datetime.fromisoformat(iso).timestamp() * 1000)
|